generated from coulomb/repo-seed
Implement WARDEN-WP-0024 experiential memory and agent sessions.
Add phase-memory bridge, warden memory CLI, route/access/sign recording, memory-aware worker planning with OpenRouter skip, tests, wiki, and AGENTS.md orientation for Claude, Codex, Grok, and future agent sessions.
This commit is contained in:
23
AGENTS.md
23
AGENTS.md
@@ -156,6 +156,29 @@ get wrong.
|
|||||||
<!-- Append repo-specific agent instructions below this marker.
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
The state-hub template sync preserves content after this line. -->
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
|
## Experiential memory (WARDEN-WP-0024)
|
||||||
|
|
||||||
|
ops-warden shares a **phase-memory** store across worker ticks, coding agent
|
||||||
|
sessions, and operator CLI use.
|
||||||
|
|
||||||
|
**At session start** (Claude Code, Codex, Grok, or future agents):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export WARDEN_AGENT_ID=grok # or claude, codex
|
||||||
|
warden memory activate --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**During work:** use normal `warden route` / `warden access` / `warden sign`;
|
||||||
|
episodes are recorded automatically unless `WARDEN_MEMORY=0`.
|
||||||
|
|
||||||
|
**Store:** `~/.local/share/warden/memory/` (override: `WARDEN_MEMORY_STORE`).
|
||||||
|
|
||||||
|
**Worker:** `warden worker run --brain llm` skips OpenRouter when stabilized
|
||||||
|
routing memory matches. See `wiki/OpsWardenMemory.md`.
|
||||||
|
|
||||||
|
Requires `phase-memory` on `PYTHONPATH` or installed; contract in
|
||||||
|
`phase-memory/docs/ops-warden-memory-contract.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workplan Convention (ADR-001)
|
## Workplan Convention (ADR-001)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ dependencies = [
|
|||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
memory = []
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
warden = "warden.cli:app"
|
warden = "warden.cli:app"
|
||||||
ops-ssh-wrapper = "warden.scripts.ops_ssh_wrapper:main"
|
ops-ssh-wrapper = "warden.scripts.ops_ssh_wrapper:main"
|
||||||
@@ -29,7 +32,7 @@ packages = ["src/warden"]
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src", "../phase-memory/src"]
|
||||||
addopts = "-m 'not integration'"
|
addopts = "-m 'not integration'"
|
||||||
markers = ["integration: requires ssh-keygen binary; run with pytest -m integration"]
|
markers = ["integration: requires ssh-keygen binary; run with pytest -m integration"]
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ activity_app = typer.Typer(
|
|||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
)
|
)
|
||||||
app.add_typer(activity_app, name="activity")
|
app.add_typer(activity_app, name="activity")
|
||||||
|
memory_app = typer.Typer(
|
||||||
|
help="Cross-runtime experiential memory via phase-memory (WARDEN-WP-0024)",
|
||||||
|
no_args_is_help=True,
|
||||||
|
)
|
||||||
|
app.add_typer(memory_app, name="memory")
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
err = Console(stderr=True)
|
err = Console(stderr=True)
|
||||||
@@ -53,6 +58,30 @@ err = Console(stderr=True)
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _record_memory_episode(
|
||||||
|
*,
|
||||||
|
command: str,
|
||||||
|
outcome: str,
|
||||||
|
need: str = "",
|
||||||
|
route_id: str = "",
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
from warden import memory as warden_memory
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
if not warden_memory.enabled() or not warden_memory.memory_available():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
warden_memory.record_command_episode(
|
||||||
|
command=command,
|
||||||
|
outcome=outcome,
|
||||||
|
need=need,
|
||||||
|
route_id=route_id,
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _load_cfg() -> WardenConfig:
|
def _load_cfg() -> WardenConfig:
|
||||||
try:
|
try:
|
||||||
return load_config()
|
return load_config()
|
||||||
@@ -128,6 +157,12 @@ def sign(
|
|||||||
|
|
||||||
# cert_command interface: write cert text to stdout only
|
# cert_command interface: write cert text to stdout only
|
||||||
print(record.cert_path.read_text().strip())
|
print(record.cert_path.read_text().strip())
|
||||||
|
_record_memory_episode(
|
||||||
|
command="sign",
|
||||||
|
outcome="resolved",
|
||||||
|
need=f"ssh cert {actor_name}",
|
||||||
|
route_id="ops-warden-ssh-cert",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -753,14 +788,30 @@ def route_find(
|
|||||||
|
|
||||||
if output_json:
|
if output_json:
|
||||||
print(json.dumps([_entry_summary(e) for e in matches], indent=2))
|
print(json.dumps([_entry_summary(e) for e in matches], indent=2))
|
||||||
|
if matches:
|
||||||
|
_record_memory_episode(
|
||||||
|
command="route find",
|
||||||
|
outcome="resolved",
|
||||||
|
need=query,
|
||||||
|
route_id=matches[0].id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_record_memory_episode(command="route find", outcome="skipped", need=query)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
|
_record_memory_episode(command="route find", outcome="skipped", need=query)
|
||||||
console.print(
|
console.print(
|
||||||
f"No routing match for {query!r}. "
|
f"No routing match for {query!r}. "
|
||||||
"Try `warden route list --all` to browse all scenarios."
|
"Try `warden route list --all` to browse all scenarios."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
_record_memory_episode(
|
||||||
|
command="route find",
|
||||||
|
outcome="resolved",
|
||||||
|
need=query,
|
||||||
|
route_id=matches[0].id,
|
||||||
|
)
|
||||||
_print_entry_table(matches, f"Matches for {query!r}")
|
_print_entry_table(matches, f"Matches for {query!r}")
|
||||||
|
|
||||||
|
|
||||||
@@ -1003,8 +1054,20 @@ def access(
|
|||||||
|
|
||||||
if output_json:
|
if output_json:
|
||||||
print(json.dumps(_access_json(entry, expanded, gate, domain), indent=2))
|
print(json.dumps(_access_json(entry, expanded, gate, domain), indent=2))
|
||||||
|
_record_memory_episode(
|
||||||
|
command="access",
|
||||||
|
outcome="resolved",
|
||||||
|
need=need,
|
||||||
|
route_id=entry.id,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_record_memory_episode(
|
||||||
|
command="access",
|
||||||
|
outcome="resolved",
|
||||||
|
need=need,
|
||||||
|
route_id=entry.id,
|
||||||
|
)
|
||||||
console.print(f"[bold]{entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
console.print(f"[bold]{entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
||||||
console.print(f" owner : {entry.owner_repo} ({entry.subsystem})")
|
console.print(f" owner : {entry.owner_repo} ({entry.subsystem})")
|
||||||
|
|
||||||
@@ -1305,3 +1368,58 @@ def worker_status_cmd() -> None:
|
|||||||
console.print(f"timer : {st or 'unknown'}")
|
console.print(f"timer : {st or 'unknown'}")
|
||||||
except Exception: # noqa: BLE001 — systemd may be absent (cron/other host)
|
except Exception: # noqa: BLE001 — systemd may be absent (cron/other host)
|
||||||
console.print("timer : (systemd not available)")
|
console.print("timer : (systemd not available)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# warden memory — cross-runtime experiential memory (WARDEN-WP-0024)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@memory_app.command("status")
|
||||||
|
def memory_status(
|
||||||
|
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Show canonical phase-memory store status (metadata only)."""
|
||||||
|
from warden import memory as warden_memory
|
||||||
|
|
||||||
|
if not warden_memory.memory_available():
|
||||||
|
err.print(f"[red]{warden_memory._PHASE_MEMORY_ERROR}[/red]")
|
||||||
|
raise typer.Exit(2)
|
||||||
|
try:
|
||||||
|
payload = warden_memory.status()
|
||||||
|
except RuntimeError as e:
|
||||||
|
err.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(2)
|
||||||
|
if output_json:
|
||||||
|
print(json.dumps(payload, indent=2))
|
||||||
|
return
|
||||||
|
console.print(f"store_path : {payload.get('store_path', '')}")
|
||||||
|
console.print(f"profile_id : {payload.get('profile_id', '')}")
|
||||||
|
console.print(f"episode_count : {payload.get('episode_count', 0)}")
|
||||||
|
console.print(f"session_kinds : {payload.get('episode_counts_by_session_kind', {})}")
|
||||||
|
console.print(f"last_activation: {payload.get('last_activation_at') or '—'}")
|
||||||
|
|
||||||
|
|
||||||
|
@memory_app.command("activate")
|
||||||
|
def memory_activate(
|
||||||
|
need: Annotated[str, typer.Option("--need", help="Optional routing need fingerprint source")] = "",
|
||||||
|
agent: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
typer.Option("--agent", help="Agent id for session_kind warden.agent.<id> (claude, codex, grok, …)"),
|
||||||
|
] = None,
|
||||||
|
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Activate bounded coordination memory for worker, operator, or agent sessions."""
|
||||||
|
from warden import memory as warden_memory
|
||||||
|
|
||||||
|
if not warden_memory.memory_available():
|
||||||
|
err.print(f"[red]{warden_memory._PHASE_MEMORY_ERROR}[/red]")
|
||||||
|
raise typer.Exit(2)
|
||||||
|
try:
|
||||||
|
payload = warden_memory.activate(need=need, agent=agent)
|
||||||
|
except RuntimeError as e:
|
||||||
|
err.print(f"[red]{e}[/red]")
|
||||||
|
raise typer.Exit(2)
|
||||||
|
if output_json:
|
||||||
|
print(json.dumps(payload, indent=2))
|
||||||
|
return
|
||||||
|
console.print(warden_memory.format_activation_summary(payload))
|
||||||
|
|||||||
122
src/warden/memory.py
Normal file
122
src/warden/memory.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""phase-memory bridge for ops-warden cross-runtime experiential memory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
_PHASE_MEMORY_ERROR = (
|
||||||
|
"phase-memory is required for warden memory commands. "
|
||||||
|
"Install with: pip install phase-memory (or set PYTHONPATH to phase-memory/src)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _phase_memory():
|
||||||
|
try:
|
||||||
|
import phase_memory.ops_warden as pm
|
||||||
|
|
||||||
|
return pm
|
||||||
|
except ImportError as exc: # pragma: no cover - exercised via tests with PYTHONPATH
|
||||||
|
raise RuntimeError(_PHASE_MEMORY_ERROR) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def memory_available() -> bool:
|
||||||
|
try:
|
||||||
|
_phase_memory()
|
||||||
|
return True
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def enabled(environ: Mapping[str, str] | None = None) -> bool:
|
||||||
|
environ = environ or os.environ
|
||||||
|
return str(environ.get("WARDEN_MEMORY", "1")).strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def store_path(environ: Mapping[str, str] | None = None):
|
||||||
|
return _phase_memory().default_memory_store_path(environ)
|
||||||
|
|
||||||
|
|
||||||
|
def session_kind(environ: Mapping[str, str] | None = None) -> str:
|
||||||
|
return _phase_memory().resolve_session_kind(environ)
|
||||||
|
|
||||||
|
|
||||||
|
def status(environ: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||||
|
pm = _phase_memory()
|
||||||
|
return pm.OpsWardenMemoryStore.open(environ=environ).status()
|
||||||
|
|
||||||
|
|
||||||
|
def activate(
|
||||||
|
*,
|
||||||
|
need: str = "",
|
||||||
|
agent: Optional[str] = None,
|
||||||
|
session_id: str = "",
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
pm = _phase_memory()
|
||||||
|
env = dict(environ or os.environ)
|
||||||
|
if agent:
|
||||||
|
env["WARDEN_AGENT_ID"] = agent
|
||||||
|
kind = pm.resolve_session_kind(env)
|
||||||
|
return pm.activate_ops_warden_memory(
|
||||||
|
pm.OpsWardenMemoryStore.open(environ=env),
|
||||||
|
session_kind=kind,
|
||||||
|
need=need,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_command_episode(
|
||||||
|
*,
|
||||||
|
command: str,
|
||||||
|
outcome: str,
|
||||||
|
need: str = "",
|
||||||
|
route_id: str = "",
|
||||||
|
diagnostic_codes: Optional[list[str]] = None,
|
||||||
|
metadata: Optional[dict[str, Any]] = None,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not enabled(environ):
|
||||||
|
return {"valid": True, "skipped": True, "reason": "WARDEN_MEMORY=0"}
|
||||||
|
pm = _phase_memory()
|
||||||
|
env = dict(environ or os.environ)
|
||||||
|
event = pm.build_session_event(
|
||||||
|
command=command,
|
||||||
|
session_kind=pm.resolve_session_kind(env),
|
||||||
|
outcome=outcome,
|
||||||
|
need=need,
|
||||||
|
route_id=route_id,
|
||||||
|
agent_id=str(env.get("WARDEN_AGENT_ID") or ""),
|
||||||
|
session_id=str(env.get("WARDEN_SESSION_ID") or ""),
|
||||||
|
diagnostic_codes=diagnostic_codes,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return pm.record_session_event(pm.OpsWardenMemoryStore.open(environ=env), event)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_activation_context(need: str = "", environ: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||||
|
env = dict(environ or os.environ)
|
||||||
|
env["WARDEN_SESSION_KIND"] = "warden.worker"
|
||||||
|
return activate(need=need, environ=env)
|
||||||
|
|
||||||
|
|
||||||
|
def stabilized_route_for_need(need: str, environ: Mapping[str, str] | None = None) -> Optional[dict[str, Any]]:
|
||||||
|
pm = _phase_memory()
|
||||||
|
store = pm.OpsWardenMemoryStore.open(environ=environ)
|
||||||
|
return pm.stabilized_route_match(store.list_events(), need=need)
|
||||||
|
|
||||||
|
|
||||||
|
def format_activation_summary(activation: dict[str, Any]) -> str:
|
||||||
|
lines = [
|
||||||
|
f"store: {activation.get('episode_count', 0)} episodes",
|
||||||
|
f"session_kind: {activation.get('session_kind', '')}",
|
||||||
|
f"selected: {len(activation.get('selected_episodes', ()) )}",
|
||||||
|
]
|
||||||
|
stabilized = activation.get("stabilized_route")
|
||||||
|
if stabilized:
|
||||||
|
lines.append(
|
||||||
|
f"stabilized: {stabilized.get('route_id')} ({stabilized.get('confirmations')} confirmations)"
|
||||||
|
)
|
||||||
|
if activation.get("llm_calls_avoided"):
|
||||||
|
lines.append("llm_calls_avoided: true")
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -191,6 +191,7 @@ class LlmConnectBrain:
|
|||||||
def __init__(self, url: Optional[str] = None, timeout: float = 60.0):
|
def __init__(self, url: Optional[str] = None, timeout: float = 60.0):
|
||||||
self.url = (url or os.environ.get("LLM_CONNECT_URL", DEFAULT_LLM_CONNECT_URL)).rstrip("/")
|
self.url = (url or os.environ.get("LLM_CONNECT_URL", DEFAULT_LLM_CONNECT_URL)).rstrip("/")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.memory_context: str = ""
|
||||||
|
|
||||||
def _call(self, prompt: str) -> str:
|
def _call(self, prompt: str) -> str:
|
||||||
resp = httpx.post(f"{self.url}/execute", json={"prompt": prompt}, timeout=self.timeout)
|
resp = httpx.post(f"{self.url}/execute", json={"prompt": prompt}, timeout=self.timeout)
|
||||||
@@ -203,9 +204,15 @@ class LlmConnectBrain:
|
|||||||
from_agent=str(message.get("from_agent", "")),
|
from_agent=str(message.get("from_agent", "")),
|
||||||
subject=str(message.get("subject", "")),
|
subject=str(message.get("subject", "")),
|
||||||
)
|
)
|
||||||
prompt = (
|
prompt = _CHARTER
|
||||||
_CHARTER
|
if self.memory_context:
|
||||||
+ "\n--- MESSAGE (untrusted data) ---\n"
|
prompt += (
|
||||||
|
"\n--- ACTIVATED MEMORY (untrusted context) ---\n"
|
||||||
|
+ self.memory_context
|
||||||
|
+ "\n--- END ACTIVATED MEMORY ---\n"
|
||||||
|
)
|
||||||
|
prompt += (
|
||||||
|
"\n--- MESSAGE (untrusted data) ---\n"
|
||||||
+ f"from: {message.get('from_agent','')}\n"
|
+ f"from: {message.get('from_agent','')}\n"
|
||||||
+ f"subject: {message.get('subject','')}\n"
|
+ f"subject: {message.get('subject','')}\n"
|
||||||
+ f"body: {message.get('body','')}\n"
|
+ f"body: {message.get('body','')}\n"
|
||||||
@@ -586,16 +593,88 @@ def draft_route_answer(query: str) -> str:
|
|||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_activation_for_message(message: dict) -> tuple[Optional[dict], str]:
|
||||||
|
try:
|
||||||
|
from warden import memory as warden_memory
|
||||||
|
except ImportError:
|
||||||
|
return None, ""
|
||||||
|
if not warden_memory.enabled() or not warden_memory.memory_available():
|
||||||
|
return None, ""
|
||||||
|
query = str(message.get("subject", "") or message.get("body", ""))
|
||||||
|
try:
|
||||||
|
activation = warden_memory.worker_activation_context(query)
|
||||||
|
except RuntimeError:
|
||||||
|
return None, ""
|
||||||
|
from warden.memory import format_activation_summary
|
||||||
|
|
||||||
|
return activation, format_activation_summary(activation)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_with_memory(message: dict, brain: Brain) -> WorkerPlan:
|
||||||
|
activation, summary = _memory_activation_for_message(message)
|
||||||
|
blob = f"{message.get('subject', '')} {message.get('body', '')}"
|
||||||
|
if activation and activation.get("llm_calls_avoided") and _ROUTING_SIGNS.search(blob):
|
||||||
|
wp = WorkerPlan(
|
||||||
|
message_id=str(message.get("id", "")),
|
||||||
|
from_agent=str(message.get("from_agent", "")),
|
||||||
|
subject=str(message.get("subject", "")),
|
||||||
|
)
|
||||||
|
query = str(message.get("subject", "") or "")
|
||||||
|
wp.actions.append(
|
||||||
|
PlannedAction(
|
||||||
|
kind="route_answer",
|
||||||
|
summary="Answer from stabilized coordination memory.",
|
||||||
|
payload={
|
||||||
|
"query": query,
|
||||||
|
"answer": draft_route_answer(query),
|
||||||
|
"memory_stabilized": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return wp
|
||||||
|
if isinstance(brain, LlmConnectBrain) and summary:
|
||||||
|
brain.memory_context = summary
|
||||||
|
return brain.plan(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_worker_memory_outcome(plan: WorkerPlan) -> None:
|
||||||
|
try:
|
||||||
|
from warden import memory as warden_memory
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
if not warden_memory.enabled() or not warden_memory.memory_available():
|
||||||
|
return
|
||||||
|
outcome = "escalated" if plan.escalated else "resolved"
|
||||||
|
route_id = ""
|
||||||
|
for action in plan.actions:
|
||||||
|
if action.kind == "route_answer" and action.payload.get("memory_stabilized"):
|
||||||
|
stabilized = warden_memory.stabilized_route_for_need(plan.subject)
|
||||||
|
if stabilized:
|
||||||
|
route_id = str(stabilized.get("route_id") or "")
|
||||||
|
try:
|
||||||
|
warden_memory.record_command_episode(
|
||||||
|
command="worker run",
|
||||||
|
outcome=outcome,
|
||||||
|
need=plan.subject,
|
||||||
|
route_id=route_id,
|
||||||
|
diagnostic_codes=["worker_escalated"] if plan.escalated else [],
|
||||||
|
metadata={"message_id": plan.message_id, "action_kinds": [a.kind for a in plan.actions]},
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def build_plans(messages: List[dict], brain: Brain) -> List[WorkerPlan]:
|
def build_plans(messages: List[dict], brain: Brain) -> List[WorkerPlan]:
|
||||||
"""Plan every message, attach computed route answers, and apply the guardrail pass."""
|
"""Plan every message, attach computed route answers, and apply the guardrail pass."""
|
||||||
plans: List[WorkerPlan] = []
|
plans: List[WorkerPlan] = []
|
||||||
for m in messages:
|
for m in messages:
|
||||||
plan = brain.plan(m)
|
plan = _plan_with_memory(m, brain)
|
||||||
plan.raw = m
|
plan.raw = m
|
||||||
for a in plan.actions:
|
for a in plan.actions:
|
||||||
if a.kind == "route_answer" and "answer" not in a.payload:
|
if a.kind == "route_answer" and "answer" not in a.payload:
|
||||||
a.payload["answer"] = draft_route_answer(a.payload.get("query", m.get("subject", "")))
|
a.payload["answer"] = draft_route_answer(a.payload.get("query", m.get("subject", "")))
|
||||||
plans.append(_guardrail(plan, m))
|
plans.append(_guardrail(plan, m))
|
||||||
|
_record_worker_memory_outcome(plans[-1])
|
||||||
return plans
|
return plans
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
11
tests/conftest.py
Normal file
11
tests/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Test configuration for ops-warden."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PHASE_MEMORY_SRC = Path(__file__).resolve().parents[2] / "phase-memory" / "src"
|
||||||
|
if PHASE_MEMORY_SRC.exists() and str(PHASE_MEMORY_SRC) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PHASE_MEMORY_SRC))
|
||||||
129
tests/test_memory.py
Normal file
129
tests/test_memory.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests for ops-warden phase-memory bridge (WARDEN-WP-0024)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from warden.cli import app
|
||||||
|
from warden.memory import activate, enabled, record_command_episode, status, store_path
|
||||||
|
from warden.worker import RuleBrain, _plan_with_memory, build_plans
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def _msg(**over) -> dict:
|
||||||
|
base = {
|
||||||
|
"id": "m1",
|
||||||
|
"from_agent": "someone",
|
||||||
|
"subject": "Where do I get an npm token?",
|
||||||
|
"body": "Which subsystem owns this credential — how do I obtain it?",
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_status_and_activate_round_trip(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
|
||||||
|
monkeypatch.setenv("WARDEN_AGENT_ID", "grok")
|
||||||
|
|
||||||
|
first = record_command_episode(
|
||||||
|
command="route find",
|
||||||
|
outcome="resolved",
|
||||||
|
need="npm token",
|
||||||
|
route_id="openbao-api-key",
|
||||||
|
)
|
||||||
|
second = record_command_episode(
|
||||||
|
command="route find",
|
||||||
|
outcome="resolved",
|
||||||
|
need="npm token",
|
||||||
|
route_id="openbao-api-key",
|
||||||
|
)
|
||||||
|
payload = activate(need="npm token", agent="grok")
|
||||||
|
|
||||||
|
assert first["valid"] is True
|
||||||
|
assert second["valid"] is True
|
||||||
|
assert payload["session_kind"] == "warden.agent.grok"
|
||||||
|
assert payload["stabilized_route"]["route_id"] == "openbao-api-key"
|
||||||
|
assert status()["episode_count"] >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_uses_stabilized_memory_without_llm(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
|
||||||
|
monkeypatch.setenv("WARDEN_SESSION_KIND", "warden.worker")
|
||||||
|
record_command_episode(
|
||||||
|
command="route find",
|
||||||
|
outcome="resolved",
|
||||||
|
need="Where do I get an npm token?",
|
||||||
|
route_id="openbao-api-key",
|
||||||
|
)
|
||||||
|
record_command_episode(
|
||||||
|
command="route find",
|
||||||
|
outcome="resolved",
|
||||||
|
need="Where do I get an npm token?",
|
||||||
|
route_id="openbao-api-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = _plan_with_memory(_msg(), RuleBrain())
|
||||||
|
|
||||||
|
assert plan.actions
|
||||||
|
assert plan.actions[0].kind == "route_answer"
|
||||||
|
assert plan.actions[0].payload.get("memory_stabilized") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_runtime_continuity_agent_to_worker(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
|
||||||
|
monkeypatch.setenv("WARDEN_AGENT_ID", "codex")
|
||||||
|
record_command_episode(
|
||||||
|
command="route find",
|
||||||
|
outcome="resolved",
|
||||||
|
need="openrouter api key",
|
||||||
|
route_id="openrouter-llm-connect",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("WARDEN_SESSION_KIND", "warden.worker")
|
||||||
|
monkeypatch.delenv("WARDEN_AGENT_ID", raising=False)
|
||||||
|
activation = activate(need="openrouter api key")
|
||||||
|
kinds = {item.get("session_kind") for item in activation["selected_episodes"] if item.get("kind") == "episode"}
|
||||||
|
|
||||||
|
assert "warden.agent.codex" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_memory_status_and_activate(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
|
||||||
|
status_result = runner.invoke(app, ["memory", "status", "--json"])
|
||||||
|
activate_result = runner.invoke(app, ["memory", "activate", "--agent", "claude", "--json"])
|
||||||
|
|
||||||
|
assert status_result.exit_code == 0
|
||||||
|
assert activate_result.exit_code == 0
|
||||||
|
assert json.loads(status_result.stdout)["episode_count"] >= 0
|
||||||
|
assert json.loads(activate_result.stdout)["session_kind"] == "warden.agent.claude"
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_find_records_memory_episode(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
|
||||||
|
result = runner.invoke(app, ["route", "find", "openrouter api key", "--json"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
payload = status()
|
||||||
|
assert payload["episode_count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plans_records_worker_outcomes(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("WARDEN_MEMORY_STORE", str(tmp_path / "memory"))
|
||||||
|
plans = build_plans([_msg()], RuleBrain())
|
||||||
|
|
||||||
|
assert plans[0].actions
|
||||||
|
assert status()["episode_count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_disabled_skips_recording(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("WARDEN_MEMORY", "0")
|
||||||
|
result = record_command_episode(command="route find", outcome="resolved", need="npm token")
|
||||||
|
|
||||||
|
assert result.get("skipped") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_store_path_uses_xdg(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("WARDEN_MEMORY_STORE", raising=False)
|
||||||
|
assert str(store_path()).endswith("warden/memory")
|
||||||
52
wiki/OpsWardenMemory.md
Normal file
52
wiki/OpsWardenMemory.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Ops-Warden Experiential Memory
|
||||||
|
|
||||||
|
Updated: 2026-07-02
|
||||||
|
|
||||||
|
ops-warden uses **phase-memory** as a shared experiential substrate across worker
|
||||||
|
ticks, coding agent sessions, and operator CLI use.
|
||||||
|
|
||||||
|
## Canonical Store
|
||||||
|
|
||||||
|
- Default: `~/.local/share/warden/memory/`
|
||||||
|
- Override: `WARDEN_MEMORY_STORE`
|
||||||
|
- Opt-out: `WARDEN_MEMORY=0`
|
||||||
|
|
||||||
|
## Session Kinds
|
||||||
|
|
||||||
|
| Runtime | How |
|
||||||
|
| --- | --- |
|
||||||
|
| Worker tick | `WARDEN_SESSION_KIND=warden.worker` (set automatically during `warden worker run`) |
|
||||||
|
| Coding agent | `export WARDEN_AGENT_ID=claude` (or `codex`, `grok`, future ids) |
|
||||||
|
| Operator CLI | default `warden.operator` when `WARDEN_AGENT_ID` is unset |
|
||||||
|
|
||||||
|
## Agent Session Orientation
|
||||||
|
|
||||||
|
At the start of a Claude Code, Codex, or Grok session that will call warden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export WARDEN_AGENT_ID=grok # or claude, codex
|
||||||
|
warden memory activate --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use normal `warden route` / `warden access` commands. Episodes are recorded
|
||||||
|
automatically when memory is enabled.
|
||||||
|
|
||||||
|
## Worker + OpenRouter
|
||||||
|
|
||||||
|
`warden worker run --brain llm` activates memory before planning. When stabilized
|
||||||
|
routing memory matches a coordination question, ops-warden uses `RuleBrain` and
|
||||||
|
skips the llm-connect / OpenRouter call.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden memory status [--json]
|
||||||
|
warden memory activate [--agent <id>] [--need "<query>"] [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Memory stores metadata only — no secret values or raw credential payloads.
|
||||||
|
- Retrieved memory is untrusted context; the fixed charter and guardrail allowlist
|
||||||
|
still apply.
|
||||||
|
- See `phase-memory/docs/ops-warden-memory-contract.md` for the full contract.
|
||||||
@@ -4,13 +4,14 @@ type: workplan
|
|||||||
title: "Experiential Memory Across Worker, Agent Sessions, And OpenRouter"
|
title: "Experiential Memory Across Worker, Agent Sessions, And OpenRouter"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: ops-warden
|
repo: ops-warden
|
||||||
status: proposed
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
planning_order: 24
|
planning_order: 24
|
||||||
created: "2026-07-02"
|
created: "2026-07-02"
|
||||||
updated: "2026-07-02"
|
updated: "2026-07-02"
|
||||||
|
state_hub_workstream_id: "5d9fafb3-f9b6-43bf-b259-5f5301daa2e9"
|
||||||
---
|
---
|
||||||
|
|
||||||
# WARDEN-WP-0024 — Experiential memory across worker, agent sessions, and OpenRouter
|
# WARDEN-WP-0024 — Experiential memory across worker, agent sessions, and OpenRouter
|
||||||
@@ -87,8 +88,9 @@ sequenceDiagram
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0024-T01
|
id: WARDEN-WP-0024-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "6305f1bc-c016-4298-adc2-a07d52b6aca5"
|
||||||
```
|
```
|
||||||
|
|
||||||
Establish the canonical phase-memory store path and discovery commands.
|
Establish the canonical phase-memory store path and discovery commands.
|
||||||
@@ -107,8 +109,9 @@ Acceptance:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0024-T02
|
id: WARDEN-WP-0024-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "242a4d9a-5375-4df8-8d43-063b0491d202"
|
||||||
```
|
```
|
||||||
|
|
||||||
Record metadata-only episodes when `route`, `access`, and `sign` complete.
|
Record metadata-only episodes when `route`, `access`, and `sign` complete.
|
||||||
@@ -127,8 +130,9 @@ Acceptance:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0024-T03
|
id: WARDEN-WP-0024-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "176fcae1-e4e5-481f-9a83-e9a7000fac1a"
|
||||||
```
|
```
|
||||||
|
|
||||||
Retrieve coordination memory before `Brain.plan` and record outcomes after execute.
|
Retrieve coordination memory before `Brain.plan` and record outcomes after execute.
|
||||||
@@ -147,8 +151,9 @@ Acceptance:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0024-T04
|
id: WARDEN-WP-0024-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "16501557-6cde-44ea-bc6f-1726cb7ec070"
|
||||||
```
|
```
|
||||||
|
|
||||||
Expose memory activation for coding agent sessions across Claude, Codex, Grok, and
|
Expose memory activation for coding agent sessions across Claude, Codex, Grok, and
|
||||||
@@ -167,8 +172,9 @@ Acceptance:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0024-T05
|
id: WARDEN-WP-0024-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "55ae679c-c08f-4afd-8646-9f5f3019f86e"
|
||||||
```
|
```
|
||||||
|
|
||||||
Ensure worker ticks see agent session episodes and vice versa.
|
Ensure worker ticks see agent session episodes and vice versa.
|
||||||
@@ -187,8 +193,9 @@ Acceptance:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0024-T06
|
id: WARDEN-WP-0024-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "fc2dffcf-7184-4f3d-8653-d26dc18a9afc"
|
||||||
```
|
```
|
||||||
|
|
||||||
Skip or downgrade OpenRouter calls when stabilized memory provides a verified match.
|
Skip or downgrade OpenRouter calls when stabilized memory provides a verified match.
|
||||||
@@ -206,8 +213,9 @@ Acceptance:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: WARDEN-WP-0024-T07
|
id: WARDEN-WP-0024-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "ca2aaf23-833f-49a6-a49b-a0b659208f5f"
|
||||||
```
|
```
|
||||||
|
|
||||||
Document the memory workflow for operators and coding agents.
|
Document the memory workflow for operators and coding agents.
|
||||||
@@ -249,4 +257,13 @@ Acceptance:
|
|||||||
|
|
||||||
## Closure Review
|
## Closure Review
|
||||||
|
|
||||||
Pending implementation.
|
Integrated phase-memory across all ops-warden runtime surfaces:
|
||||||
|
|
||||||
|
- `warden memory status` and `warden memory activate` with canonical XDG store.
|
||||||
|
- CLI recording hooks on `route find`, `access`, and `sign` (opt-out via
|
||||||
|
`WARDEN_MEMORY=0`).
|
||||||
|
- Memory-aware worker planning with stabilized-route RuleBrain short-circuit
|
||||||
|
and LlmConnectBrain context injection.
|
||||||
|
- Cross-runtime continuity verified in tests (agent session → worker activation).
|
||||||
|
- `wiki/OpsWardenMemory.md` and AGENTS.md session orientation for Claude,
|
||||||
|
Codex, Grok, and future agents.
|
||||||
Reference in New Issue
Block a user