Skip to content

Commit 11025db

Browse files
committed
feat(install): persist memory learning settings
1 parent c502087 commit 11025db

8 files changed

Lines changed: 404 additions & 52 deletions

File tree

headroom/cli/install.py

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,12 @@ def _build_deployment_manifest(
344344
backend: str,
345345
anyllm_provider: str | None,
346346
region: str | None,
347-
proxy_mode: str,
347+
proxy_mode: str | None,
348348
memory: bool,
349+
learn_enabled: bool | None,
350+
memory_storage_mode: str,
351+
traffic_learning_min_evidence: int,
352+
memory_project_root: str,
349353
telemetry: bool,
350354
no_telemetry: bool,
351355
image: str,
@@ -370,9 +374,13 @@ def _build_deployment_manifest(
370374
anyllm_provider=anyllm_provider,
371375
region=region,
372376
proxy_mode=proxy_mode,
373-
memory_enabled=memory,
377+
memory_enabled=memory or learn_enabled is True,
374378
telemetry_enabled=telemetry and not no_telemetry,
375379
image=image,
380+
learn_enabled=learn_enabled,
381+
memory_storage_mode=memory_storage_mode,
382+
traffic_learning_min_evidence=traffic_learning_min_evidence,
383+
memory_project_root=memory_project_root,
376384
no_http2=no_http2,
377385
code_aware=code_aware,
378386
intercept_tool_results=intercept_tool_results,
@@ -524,11 +532,38 @@ def _echo_installed(manifest: DeploymentManifest, *, prefix: str = "Installed pe
524532
@click.option(
525533
"--mode",
526534
"proxy_mode",
527-
default="cache",
528-
show_default=True,
529-
help="Proxy optimization mode. cache = delta-only compression at ~0 prefix-cache busts.",
535+
default=None,
536+
help="Proxy optimization mode. Omit to use runtime/settings defaults.",
530537
)
531538
@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
539+
@click.option(
540+
"--learn/--no-learn",
541+
"learn_enabled",
542+
default=None,
543+
help="Explicitly enable or disable traffic learning.",
544+
)
545+
@click.option(
546+
"--memory-storage",
547+
"memory_storage_mode",
548+
type=click.Choice(["project", "user", "global"], case_sensitive=False),
549+
default="project",
550+
show_default=True,
551+
help="Persistent memory storage scope.",
552+
)
553+
@click.option(
554+
"--min-evidence",
555+
"traffic_learning_min_evidence",
556+
type=click.IntRange(min=1),
557+
default=5,
558+
show_default=True,
559+
help="Evidence count required before learning persists a pattern.",
560+
)
561+
@click.option(
562+
"--memory-project-root",
563+
type=click.Path(path_type=str),
564+
default="",
565+
help="Override the project root used by project memory storage.",
566+
)
532567
@click.option(
533568
"--telemetry",
534569
is_flag=True,
@@ -606,8 +641,12 @@ def install_apply(
606641
backend: str,
607642
anyllm_provider: str | None,
608643
region: str | None,
609-
proxy_mode: str,
644+
proxy_mode: str | None,
610645
memory: bool,
646+
learn_enabled: bool | None,
647+
memory_storage_mode: str,
648+
traffic_learning_min_evidence: int,
649+
memory_project_root: str,
611650
telemetry: bool,
612651
no_telemetry: bool,
613652
image: str,
@@ -654,6 +693,10 @@ def install_apply(
654693
region=region,
655694
proxy_mode=proxy_mode,
656695
memory=memory,
696+
learn_enabled=learn_enabled,
697+
memory_storage_mode=memory_storage_mode,
698+
traffic_learning_min_evidence=traffic_learning_min_evidence,
699+
memory_project_root=memory_project_root,
657700
telemetry=telemetry,
658701
no_telemetry=no_telemetry,
659702
image=image,
@@ -699,9 +742,8 @@ def install_apply(
699742
@click.option(
700743
"--mode",
701744
"proxy_mode",
702-
default="cache",
703-
show_default=True,
704-
help="Proxy optimization mode. cache = delta-only compression at ~0 prefix-cache busts.",
745+
default=None,
746+
help="Proxy optimization mode. Omit to use runtime/settings defaults.",
705747
)
706748
@click.option(
707749
"--scope",
@@ -726,6 +768,34 @@ def install_apply(
726768
help="Tool target to configure when --providers manual is used.",
727769
)
728770
@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
771+
@click.option(
772+
"--learn/--no-learn",
773+
"learn_enabled",
774+
default=None,
775+
help="Explicitly enable or disable traffic learning.",
776+
)
777+
@click.option(
778+
"--memory-storage",
779+
"memory_storage_mode",
780+
type=click.Choice(["project", "user", "global"], case_sensitive=False),
781+
default="project",
782+
show_default=True,
783+
help="Persistent memory storage scope.",
784+
)
785+
@click.option(
786+
"--min-evidence",
787+
"traffic_learning_min_evidence",
788+
type=click.IntRange(min=1),
789+
default=5,
790+
show_default=True,
791+
help="Evidence count required before learning persists a pattern.",
792+
)
793+
@click.option(
794+
"--memory-project-root",
795+
type=click.Path(path_type=str),
796+
default="",
797+
help="Override the project root used by project memory storage.",
798+
)
729799
@click.option(
730800
"--telemetry",
731801
is_flag=True,
@@ -758,11 +828,15 @@ def deploy(
758828
backend: str,
759829
anyllm_provider: str | None,
760830
region: str | None,
761-
proxy_mode: str,
831+
proxy_mode: str | None,
762832
scope: str,
763833
provider_mode: str,
764834
targets: tuple[str, ...],
765835
memory: bool,
836+
learn_enabled: bool | None,
837+
memory_storage_mode: str,
838+
traffic_learning_min_evidence: int,
839+
memory_project_root: str,
766840
telemetry: bool,
767841
no_telemetry: bool,
768842
image: str,
@@ -786,6 +860,10 @@ def deploy(
786860
region=region,
787861
proxy_mode=proxy_mode,
788862
memory=memory,
863+
learn_enabled=learn_enabled,
864+
memory_storage_mode=memory_storage_mode,
865+
traffic_learning_min_evidence=traffic_learning_min_evidence,
866+
memory_project_root=memory_project_root,
789867
telemetry=telemetry,
790868
no_telemetry=no_telemetry,
791869
image=image,
@@ -809,6 +887,18 @@ def install_status(profile: str) -> None:
809887
click.echo(f"Runtime: {manifest.runtime_kind}")
810888
click.echo(f"Supervisor: {manifest.supervisor_kind}")
811889
click.echo(f"Scope: {manifest.scope}")
890+
click.echo(f"Mode: {getattr(manifest, 'proxy_mode', None) or 'runtime default'}")
891+
click.echo(
892+
f"Memory: {'enabled' if getattr(manifest, 'memory_enabled', False) else 'disabled'}"
893+
)
894+
learn_enabled = getattr(manifest, "learn_enabled", None)
895+
learning = (
896+
"runtime default" if learn_enabled is None else ("enabled" if learn_enabled else "disabled")
897+
)
898+
click.echo(f"Learning: {learning}")
899+
click.echo(f"Storage: {getattr(manifest, 'memory_storage_mode', 'project')}")
900+
click.echo(f"Min evidence: {getattr(manifest, 'traffic_learning_min_evidence', 5)}")
901+
click.echo(f"Project root: {getattr(manifest, 'memory_project_root', '') or 'runtime default'}")
812902
click.echo(f"Port: {manifest.port}")
813903
click.echo(f"Status: {runtime_status(manifest)}")
814904
click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")

headroom/install/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,13 @@ class DeploymentManifest:
102102
backend: str
103103
anyllm_provider: str | None = None
104104
region: str | None = None
105-
proxy_mode: str = "cache"
105+
proxy_mode: str | None = None
106106
memory_enabled: bool = False
107107
memory_db_path: str = ""
108+
learn_enabled: bool | None = None
109+
memory_storage_mode: str = "project"
110+
traffic_learning_min_evidence: int = 5
111+
memory_project_root: str = ""
108112
telemetry_enabled: bool = True
109113
image: str = "ghcr.io/headroomlabs-ai/headroom:latest"
110114
service_name: str = "headroom"

headroom/install/planner.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,14 @@ def build_manifest(
129129
backend: str,
130130
anyllm_provider: str | None,
131131
region: str | None,
132-
proxy_mode: str,
132+
proxy_mode: str | None,
133133
memory_enabled: bool,
134134
telemetry_enabled: bool,
135135
image: str,
136+
learn_enabled: bool | None = None,
137+
memory_storage_mode: str = "project",
138+
traffic_learning_min_evidence: int = 5,
139+
memory_project_root: str = "",
136140
no_http2: bool = False,
137141
code_aware: bool | None = None,
138142
intercept_tool_results: bool = False,
@@ -166,7 +170,6 @@ def build_manifest(
166170
base_env = {
167171
"HEADROOM_PORT": str(port),
168172
"HEADROOM_HOST": "127.0.0.1",
169-
"HEADROOM_MODE": proxy_mode,
170173
"HEADROOM_BACKEND": backend,
171174
}
172175
if anyllm_provider:
@@ -221,11 +224,11 @@ def build_manifest(
221224
"127.0.0.1",
222225
"--port",
223226
str(port),
224-
"--mode",
225-
proxy_mode,
226227
"--backend",
227228
backend,
228229
]
230+
if proxy_mode is not None:
231+
proxy_args.extend(["--mode", proxy_mode])
229232
proxy_args.append("--telemetry" if telemetry_enabled else "--no-telemetry")
230233
if memory_enabled:
231234
proxy_args.append("--memory")
@@ -241,6 +244,13 @@ def build_manifest(
241244
# runtime the resolved host path is correct, so keep passing it.
242245
if runtime_kind != RuntimeKind.DOCKER.value:
243246
proxy_args.extend(["--memory-db-path", str(_paths.memory_db_path())])
247+
if learn_enabled is not None:
248+
proxy_args.append("--learn" if learn_enabled else "--no-learn")
249+
proxy_args.extend(["--memory-storage", memory_storage_mode])
250+
if "HEADROOM_MIN_EVIDENCE" not in base_env:
251+
proxy_args.extend(["--min-evidence", str(traffic_learning_min_evidence)])
252+
if memory_project_root:
253+
proxy_args.extend(["--memory-project-root", memory_project_root])
244254
if anyllm_provider:
245255
proxy_args.extend(["--anyllm-provider", anyllm_provider])
246256
if region:
@@ -276,6 +286,10 @@ def build_manifest(
276286
proxy_mode=proxy_mode,
277287
memory_enabled=memory_enabled,
278288
memory_db_path=str(_paths.memory_db_path()),
289+
learn_enabled=learn_enabled,
290+
memory_storage_mode=memory_storage_mode,
291+
traffic_learning_min_evidence=traffic_learning_min_evidence,
292+
memory_project_root=memory_project_root,
279293
telemetry_enabled=telemetry_enabled,
280294
image=image,
281295
service_name=f"headroom-{normalized_profile}",

headroom/install/state.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,26 @@ def _migrate_deprecated_image(image: Any) -> Any:
7878
return image
7979

8080

81+
def _migrate_manifest_payload(payload: dict[str, Any]) -> dict[str, Any]:
82+
"""Apply safe in-memory migrations for older deployment manifests."""
83+
84+
if "image" in payload:
85+
payload["image"] = _migrate_deprecated_image(payload["image"])
86+
# Mode is represented by the proxy argument generated from ``proxy_mode``.
87+
# Older manifests also exported it through the supervisor environment,
88+
# creating two competing runtime inputs. Remove only the matching derived
89+
# copy; a different or mode-less environment value remains an explicit input.
90+
base_env = payload.get("base_env")
91+
proxy_mode = payload.get("proxy_mode")
92+
if (
93+
isinstance(base_env, dict)
94+
and proxy_mode is not None
95+
and base_env.get("HEADROOM_MODE") == proxy_mode
96+
):
97+
base_env.pop("HEADROOM_MODE", None)
98+
return payload
99+
100+
81101
def load_manifest(profile: str = "default") -> DeploymentManifest | None:
82102
"""Load a deployment manifest when present."""
83103

@@ -92,9 +112,7 @@ def load_manifest(profile: str = "default") -> DeploymentManifest | None:
92112
payload = json.loads(path.read_text(encoding="utf-8"))
93113
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
94114
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
95-
if "image" in payload:
96-
payload["image"] = _migrate_deprecated_image(payload["image"])
97-
return DeploymentManifest(**payload)
115+
return DeploymentManifest(**_migrate_manifest_payload(payload))
98116
except (json.JSONDecodeError, ValueError, TypeError, OSError) as e:
99117
raise ManifestError(f"deployment profile '{profile}' is corrupt ({path}): {e}") from e
100118

@@ -114,9 +132,7 @@ def list_manifests() -> list[DeploymentManifest]:
114132
ManagedMutation(**item) for item in payload.get("mutations", [])
115133
]
116134
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
117-
if "image" in payload:
118-
payload["image"] = _migrate_deprecated_image(payload["image"])
119-
manifests.append(DeploymentManifest(**payload))
135+
manifests.append(DeploymentManifest(**_migrate_manifest_payload(payload)))
120136
except (OSError, ValueError, TypeError):
121137
continue
122138
return manifests

tests/test_cli/test_install_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,41 @@ class Manifest:
387387
assert "Backend: anthropic" in result.output
388388

389389

390+
def test_install_status_reports_effective_memory_options(monkeypatch) -> None:
391+
runner = CliRunner()
392+
393+
class Manifest:
394+
profile = "default"
395+
preset = "persistent-service"
396+
runtime_kind = "python"
397+
supervisor_kind = "service"
398+
scope = "user"
399+
port = 8787
400+
backend = "anthropic"
401+
health_url = "http://127.0.0.1:8787/readyz"
402+
proxy_mode = None
403+
memory_enabled = True
404+
learn_enabled = False
405+
memory_storage_mode = "global"
406+
traffic_learning_min_evidence = 7
407+
memory_project_root = "/tmp/scratch-project"
408+
409+
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
410+
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running")
411+
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True)
412+
monkeypatch.setattr("headroom.cli.install.probe_json", lambda url: {"config": {}})
413+
414+
result = runner.invoke(main, ["install", "status"])
415+
416+
assert result.exit_code == 0, result.output
417+
assert "Mode: runtime default" in result.output
418+
assert "Memory: enabled" in result.output
419+
assert "Learning: disabled" in result.output
420+
assert "Storage: global" in result.output
421+
assert "Min evidence: 7" in result.output
422+
assert "Project root: /tmp/scratch-project" in result.output
423+
424+
390425
def test_install_restart_uses_internal_helpers(monkeypatch) -> None:
391426
runner = CliRunner()
392427
calls: list[str] = []

0 commit comments

Comments
 (0)