Skip to content

Commit a7f12a4

Browse files
feat(memory-primitive): Phase 2 — hindsight adapter + e2e integration tests
Adds the first provider adapter under providers/workspaces/claude-cli/memory/hindsight/: - init.sh — translates AGENTIC_MEMORY_* contract into HINDSIGHT_* env vars. Sets HINDSIGHT_DYNAMIC_BANK_ID=false explicitly (so the env var HINDSIGHT_BANK_ID actually takes effect — verified empirically in agentic-memory probe bank-derivation-modes). Writes ~/.hindsight/claude-code.json when AGENTIC_MEMORY_CONFIG_JSON is supplied. - doctor.sh — provider-specific health check (called from Python's ProviderSpecificCheck). Verifies the backend's GET /v1/default/banks returns 200 and checks bank membership ("not in list" = lazy-create pending = pass). Auto-fixes stale ~/.hindsight/claude-code.json configs that set dynamicBankId:true (a stale-state issue where HINDSIGHT_BANK_ID env override is silently ignored). Python is used for JSON parsing — robust against malformed configs. API discovery: hindsight 0.6.x no longer supports GET /v1/default/banks/<id> (returns 405). doctor.sh uses the list endpoint and filters in Python. Integration tests at tests/integration/test_entrypoint_memory.py mirror test_entrypoint_workspace_injection.py: 1. No provider → entrypoint completes normally (sections 5.6/5.7 no-op) 2. Provider + reachable backend → doctor pass, adapter env vars exported, AGENTIC_MEMORY_READY=1, audit JSONL written 3. Provider + unreachable backend → doctor fail, container exits 1 4. Provider + missing namespace → env_contract check fails 5. Unknown provider name → provider_known check fails 6. Stale dynamicBankId:true config → auto-fixed to false, other keys preserved 7. /opt/agentic/memory/doctor with no provider → exit 0 (no-op) 8. /opt/agentic/memory/doctor --json → JSON to stdout, exit 1 8/8 integration tests passing against the rebuilt agentic-workspace-claude-cli:latest image. Combined with 53 unit tests in lib/python/agentic_memory/, full coverage of the contract + doctor + adapter end-to-end. Discovered + fixed during testing: - API endpoint shape change (GET /banks/<id> → use list endpoint) - Entrypoint log lines go to stdout per existing convention (not stderr) — tests updated accordingly
1 parent c510121 commit a7f12a4

3 files changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env bash
2+
# Hindsight provider-specific health checks (ADR-036 check 8).
3+
#
4+
# Called by /opt/agentic/memory/doctor's ProviderSpecificCheck. Reports
5+
# JSON to stdout describing findings; exit 0 = pass, exit 1 = fail.
6+
#
7+
# Checks:
8+
# - bank_reachable: GET /v1/default/banks/<bank>
9+
# 200 = bank exists (good)
10+
# 404 = bank does not exist yet — fine, hindsight lazy-creates on
11+
# first retain. Still considered pass.
12+
# other = fail.
13+
# - dynamic_bank_id_consistent: if ~/.hindsight/claude-code.json exists
14+
# and has dynamicBankId !== false, the HINDSIGHT_BANK_ID env var
15+
# the adapter set is silently ignored by the hindsight plugin
16+
# (verified in hindsight bank.py:97). Auto-fix: rewrite the file
17+
# with dynamicBankId: false. This is a stale-state issue, not an
18+
# operator decision.
19+
20+
set -e
21+
22+
emit_json() {
23+
local status="$1"
24+
local details="$2"
25+
printf '{"hindsight_provider_check":"%s","details":%s}\n' "$status" "$details"
26+
}
27+
28+
# --- Check 1: bank_reachable --------------------------------------------------
29+
# Hindsight 0.6.x does not expose `GET /v1/default/banks/<id>` (returns 405).
30+
# Use the list endpoint and filter — "not in list" is fine because hindsight
31+
# lazy-creates banks on first retain.
32+
LIST_URL="${HINDSIGHT_API_URL}/v1/default/banks"
33+
HTTP_STATUS=$(curl -sS -o /tmp/hindsight-doctor-body -w "%{http_code}" \
34+
--max-time 5 \
35+
${HINDSIGHT_API_TOKEN:+-H "Authorization: Bearer ${HINDSIGHT_API_TOKEN}"} \
36+
"${LIST_URL}" || echo "000")
37+
38+
if [ "${HTTP_STATUS}" != "200" ]; then
39+
emit_json "fail" "$(printf '{"check":"bank_reachable","url":"%s","http_status":"%s","body_preview":"%s"}' \
40+
"${LIST_URL}" "${HTTP_STATUS}" "$(head -c 200 /tmp/hindsight-doctor-body 2>/dev/null | tr '\n' ' ')")"
41+
exit 1
42+
fi
43+
44+
# Check membership. If the bank is in the list, status=exists; otherwise
45+
# lazy-create-pending (both pass).
46+
bank_status=$(python3 -c "
47+
import json, sys
48+
try:
49+
with open('/tmp/hindsight-doctor-body') as f:
50+
data = json.load(f)
51+
banks = data.get('banks', [])
52+
bank_ids = [b.get('bank_id') for b in banks if isinstance(b, dict)]
53+
print('exists' if '${HINDSIGHT_BANK_ID}' in bank_ids else 'lazy_create_pending')
54+
except Exception:
55+
print('lazy_create_pending')
56+
" 2>/dev/null || echo "lazy_create_pending")
57+
58+
# --- Check 2: dynamic_bank_id_consistent (with auto-fix) ----------------------
59+
HINDSIGHT_CONFIG="${HOME}/.hindsight/claude-code.json"
60+
config_action="no_config_file"
61+
62+
if [ -f "${HINDSIGHT_CONFIG}" ]; then
63+
# Parse dynamicBankId field; default to false if file is malformed or key
64+
# absent. Use python3 (always present in this image) for robust JSON parsing.
65+
dyn_bank_id=$(python3 -c "
66+
import json, sys
67+
try:
68+
with open('${HINDSIGHT_CONFIG}') as f:
69+
cfg = json.load(f)
70+
print('true' if cfg.get('dynamicBankId') else 'false')
71+
except Exception:
72+
print('parse-error')
73+
" 2>/dev/null || echo "parse-error")
74+
75+
case "${dyn_bank_id}" in
76+
false)
77+
config_action="config_consistent"
78+
;;
79+
true)
80+
# Auto-fix: rewrite with dynamicBankId: false. The contract's intent
81+
# is explicit bank-id; a stale config saying otherwise is wrong.
82+
python3 -c "
83+
import json
84+
with open('${HINDSIGHT_CONFIG}') as f:
85+
cfg = json.load(f)
86+
cfg['dynamicBankId'] = False
87+
with open('${HINDSIGHT_CONFIG}', 'w') as f:
88+
json.dump(cfg, f, indent=2)
89+
" 2>/dev/null
90+
config_action="auto_fixed_dynamic_bank_id"
91+
;;
92+
parse-error)
93+
emit_json "fail" "$(printf '{"check":"dynamic_bank_id_consistent","error":"config_file_unreadable","path":"%s"}' "${HINDSIGHT_CONFIG}")"
94+
exit 1
95+
;;
96+
esac
97+
fi
98+
99+
# Success: emit a single JSON object with both check results.
100+
emit_json "ok" "$(printf '{"bank":"%s","bank_status":"%s","config_action":"%s"}' \
101+
"${HINDSIGHT_BANK_ID}" "${bank_status}" "${config_action}")"
102+
exit 0
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
# Hindsight memory provider adapter.
3+
#
4+
# Translates the AGENTIC_MEMORY_* contract (ADR-036) into the HINDSIGHT_*
5+
# env vars the hindsight Claude Code plugin reads.
6+
#
7+
# Called by /opt/agentic/entrypoint.sh section 5.6 when
8+
# AGENTIC_MEMORY_PROVIDER=hindsight. Sourced into the parent shell so the
9+
# exports propagate to subsequent process spawns.
10+
#
11+
# Provider-specific failure modes are caught by /opt/agentic/memory/doctor
12+
# via this directory's `doctor.sh` (called from section 5.7).
13+
14+
set -e
15+
16+
# --- Backend URL --------------------------------------------------------------
17+
export HINDSIGHT_API_URL="${AGENTIC_MEMORY_URL}"
18+
19+
# --- Auth (optional) ----------------------------------------------------------
20+
if [ -n "${AGENTIC_MEMORY_AUTH:-}" ]; then
21+
export HINDSIGHT_API_TOKEN="${AGENTIC_MEMORY_AUTH}"
22+
fi
23+
24+
# --- Bank scoping -------------------------------------------------------------
25+
# HINDSIGHT_BANK_ID env override is honored only when dynamicBankId=false
26+
# (verified empirically in agentic-memory's bank-derivation-modes probe).
27+
# Force static bank-id mode so the contract's namespace actually takes effect.
28+
export HINDSIGHT_DYNAMIC_BANK_ID=false
29+
export HINDSIGHT_BANK_ID="${AGENTIC_MEMORY_NAMESPACE}"
30+
31+
# --- Optional rich config -----------------------------------------------------
32+
# AGENTIC_MEMORY_CONFIG_JSON is the escape hatch for adapter-specific config
33+
# the core contract doesn't model (e.g. recallAdditionalBanks). Written to
34+
# the path the hindsight plugin already knows how to read.
35+
if [ -n "${AGENTIC_MEMORY_CONFIG_JSON:-}" ]; then
36+
mkdir -p "${HOME}/.hindsight"
37+
printf '%s' "${AGENTIC_MEMORY_CONFIG_JSON}" > "${HOME}/.hindsight/claude-code.json"
38+
fi
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""Integration tests for the memory primitive entrypoint sections 5.6 + 5.7.
2+
3+
Mirrors the pattern in test_entrypoint_workspace_injection.py — runs the
4+
real workspace container with varying AGENTIC_MEMORY_* env vars and
5+
asserts the doctor behavior end-to-end.
6+
7+
Some tests require a running hindsight backend reachable at
8+
host.docker.internal:9077. They are skipped when the backend is
9+
unreachable; you can spin one up via either:
10+
11+
uvx hindsight-embed@latest daemon --profile claude-code start
12+
13+
Or via the agentic-memory repo's docker compose stack.
14+
15+
See ADR-036 + spec 2026-05-13-memory-primitive-and-doctor-design.md.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
import os
22+
import subprocess
23+
import urllib.error
24+
import urllib.request
25+
from pathlib import Path
26+
27+
import pytest
28+
29+
IMAGE = os.getenv("AGENTIC_WORKSPACE_IMAGE", "agentic-workspace-claude-cli:latest")
30+
HINDSIGHT_BACKEND_URL = os.getenv(
31+
"HINDSIGHT_BACKEND_URL_FROM_HOST",
32+
"http://127.0.0.1:9077",
33+
)
34+
35+
36+
def _hindsight_reachable() -> bool:
37+
"""True if the hindsight backend's /health responds 200 from the host."""
38+
try:
39+
with urllib.request.urlopen( # noqa: S310
40+
f"{HINDSIGHT_BACKEND_URL}/health",
41+
timeout=2,
42+
) as resp:
43+
return resp.status == 200
44+
except (urllib.error.URLError, TimeoutError, OSError):
45+
return False
46+
47+
48+
def _run(
49+
args: list[str],
50+
env: dict[str, str] | None = None,
51+
extra_mounts: list[str] | None = None,
52+
add_host_gateway: bool = False,
53+
) -> subprocess.CompletedProcess:
54+
"""Run the workspace image with tmpfs home, optional env / mounts."""
55+
cmd = [
56+
"docker", "run", "--rm",
57+
"--tmpfs=/home/agent:rw,exec,nosuid,size=128m,uid=1000,gid=1000",
58+
]
59+
if add_host_gateway:
60+
cmd.extend(["--add-host=host.docker.internal:host-gateway"])
61+
for m in extra_mounts or []:
62+
cmd.extend(["-v", m])
63+
for k, v in (env or {}).items():
64+
cmd.extend(["-e", f"{k}={v}"])
65+
cmd.append(IMAGE)
66+
cmd.extend(args)
67+
return subprocess.run(cmd, capture_output=True, text=True, timeout=120)
68+
69+
70+
@pytest.mark.integration
71+
def test_no_provider_is_noop():
72+
"""When AGENTIC_MEMORY_PROVIDER is unset, sections 5.6 + 5.7 do nothing."""
73+
result = _run(["echo", "agent reached"])
74+
assert result.returncode == 0, f"container failed: {result.stderr}"
75+
assert "agent reached" in result.stdout
76+
assert "memory doctor" not in result.stderr
77+
assert "memory adapter" not in result.stderr
78+
79+
80+
@pytest.mark.integration
81+
@pytest.mark.skipif(not _hindsight_reachable(), reason="hindsight backend unreachable")
82+
def test_provider_with_reachable_backend_passes(tmp_path: Path):
83+
"""Reachable backend + valid env → doctor passes, adapter sets HINDSIGHT_*."""
84+
audit_dir = tmp_path / "audit"
85+
audit_dir.mkdir()
86+
87+
result = _run(
88+
[
89+
"bash", "-c",
90+
"echo agent reached; "
91+
"echo HINDSIGHT_BANK_ID=$HINDSIGHT_BANK_ID; "
92+
"echo HINDSIGHT_API_URL=$HINDSIGHT_API_URL; "
93+
"echo HINDSIGHT_DYNAMIC_BANK_ID=$HINDSIGHT_DYNAMIC_BANK_ID; "
94+
"echo AGENTIC_MEMORY_READY=$AGENTIC_MEMORY_READY",
95+
],
96+
env={
97+
"AGENTIC_MEMORY_PROVIDER": "hindsight",
98+
"AGENTIC_MEMORY_NAMESPACE": "integration-test-pass",
99+
"AGENTIC_MEMORY_URL": "http://host.docker.internal:9077",
100+
},
101+
extra_mounts=[f"{audit_dir}:/var/agentic/memory-doctor"],
102+
add_host_gateway=True,
103+
)
104+
105+
assert result.returncode == 0, f"container failed: {result.stderr}"
106+
combined = result.stdout + result.stderr
107+
assert "agent reached" in result.stdout
108+
assert "HINDSIGHT_BANK_ID=integration-test-pass" in result.stdout
109+
assert "HINDSIGHT_API_URL=http://host.docker.internal:9077" in result.stdout
110+
assert "HINDSIGHT_DYNAMIC_BANK_ID=false" in result.stdout
111+
assert "AGENTIC_MEMORY_READY=1" in result.stdout
112+
# Entrypoint log lines go to stdout (matches existing entrypoint convention)
113+
assert "memory doctor: pass" in combined
114+
assert "memory adapter: hindsight" in combined
115+
116+
audit_files = list(audit_dir.glob("*.jsonl"))
117+
assert len(audit_files) == 1
118+
payload = json.loads(audit_files[0].read_text().splitlines()[-1])
119+
assert payload["status"] == "ok"
120+
assert payload["exit_code"] == 0
121+
assert payload["provider"] == "hindsight"
122+
123+
124+
@pytest.mark.integration
125+
def test_provider_with_unreachable_backend_hard_fails():
126+
"""Backend unreachable → doctor exits 1; container does NOT reach CMD."""
127+
result = _run(
128+
["echo", "should not reach here"],
129+
env={
130+
"AGENTIC_MEMORY_PROVIDER": "hindsight",
131+
"AGENTIC_MEMORY_NAMESPACE": "bad-backend-test",
132+
"AGENTIC_MEMORY_URL": "http://nonexistent.invalid:9999",
133+
},
134+
)
135+
136+
assert result.returncode != 0
137+
assert "should not reach here" not in result.stdout
138+
# FAIL log message goes to stderr per the entrypoint section 5.7
139+
assert "memory doctor: FAIL" in result.stdout + result.stderr
140+
141+
142+
@pytest.mark.integration
143+
def test_provider_with_missing_namespace_hard_fails():
144+
"""env_contract check catches missing required vars."""
145+
result = _run(
146+
["echo", "should not reach here"],
147+
env={
148+
"AGENTIC_MEMORY_PROVIDER": "hindsight",
149+
"AGENTIC_MEMORY_URL": "http://host.docker.internal:9077",
150+
},
151+
add_host_gateway=True,
152+
)
153+
154+
assert result.returncode != 0
155+
assert "should not reach here" not in result.stdout
156+
157+
158+
@pytest.mark.integration
159+
def test_unknown_provider_hard_fails():
160+
"""provider_known check catches typo'd provider names."""
161+
result = _run(
162+
["echo", "should not reach here"],
163+
env={
164+
"AGENTIC_MEMORY_PROVIDER": "nonexistent-provider",
165+
"AGENTIC_MEMORY_NAMESPACE": "x",
166+
"AGENTIC_MEMORY_URL": "http://host.docker.internal:9077",
167+
},
168+
add_host_gateway=True,
169+
)
170+
171+
assert result.returncode != 0
172+
assert "should not reach here" not in result.stdout
173+
174+
175+
@pytest.mark.integration
176+
@pytest.mark.skipif(not _hindsight_reachable(), reason="hindsight backend unreachable")
177+
def test_auto_fix_stale_dynamic_bank_id(tmp_path: Path):
178+
"""Stale ~/.hindsight/claude-code.json with dynamicBankId:true is
179+
auto-rewritten to false. The HINDSIGHT_BANK_ID env var the adapter
180+
exports would otherwise be silently ignored by the hindsight plugin."""
181+
home = tmp_path / "agent-home"
182+
home.mkdir()
183+
config = home / "claude-code.json"
184+
config.write_text(json.dumps({
185+
"dynamicBankId": True,
186+
"dynamicBankGranularity": ["project"],
187+
"stale_marker": "should-be-preserved",
188+
}))
189+
190+
result = _run(
191+
["echo", "agent reached"],
192+
env={
193+
"AGENTIC_MEMORY_PROVIDER": "hindsight",
194+
"AGENTIC_MEMORY_NAMESPACE": "test-autofix",
195+
"AGENTIC_MEMORY_URL": "http://host.docker.internal:9077",
196+
},
197+
extra_mounts=[f"{home}:/home/agent/.hindsight"],
198+
add_host_gateway=True,
199+
)
200+
201+
assert result.returncode == 0, f"container failed: {result.stderr}"
202+
assert "agent reached" in result.stdout
203+
204+
rewritten = json.loads(config.read_text())
205+
assert rewritten["dynamicBankId"] is False
206+
assert rewritten["stale_marker"] == "should-be-preserved"
207+
208+
209+
@pytest.mark.integration
210+
def test_doctor_binary_runs_without_provider():
211+
"""`/opt/agentic/memory/doctor` invoked with no provider is a no-op (exit 0)."""
212+
result = _run(["/opt/agentic/memory/doctor"])
213+
assert result.returncode == 0
214+
assert "not opted in" in result.stderr.lower() or "no checks run" in result.stderr.lower()
215+
216+
217+
@pytest.mark.integration
218+
def test_doctor_binary_json_output():
219+
"""--json emits machine-readable output on stdout."""
220+
result = _run(
221+
[
222+
"/opt/agentic/memory/doctor",
223+
"--json",
224+
"--provider", "nonexistent-provider",
225+
"--namespace", "test",
226+
"--url", "http://nonexistent.invalid:9999",
227+
],
228+
)
229+
assert result.returncode == 1
230+
payload = json.loads(result.stdout.strip().splitlines()[-1])
231+
assert payload["status"] == "fail"
232+
assert payload["exit_code"] == 1
233+
assert any(c["name"] == "provider_known" and c["status"] == "fail" for c in payload["checks"])

0 commit comments

Comments
 (0)