refactor: modularize for coding agents (runtime, agents, boundaries)

Extract clm.runtime and clm.agents; keep session core vendor-free; inject
describe_live_event into the outer loop; enforce import boundaries and
mirror unit tests by package.
This commit is contained in:
grok-bot committed 2026-08-06 17:27:17 +02:00
1 parent b7db73a2c9
commit c5d495f830
85 files changed
+2450 -579

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
# AGENTS.md — CLM map for coding agents
**CLM (Context Lifecycle Management)** is a framework-agnostic **outer control loop** over an executing coding agent. It measures tokens/context, decides **CONTINUE / COMPACT / HANDOFF**, persists memory, and emits audit-friendly handoff manifests.
| Field | Value |
|-------|--------|
| Package | `clm` (`src/clm/`) |
| Runtime home | `.clm/` (never commit) |
| Python / tests | always `.venv/bin/python` and `.venv/bin/pytest` |
| Session continuity | [`HANDOFF.md`](HANDOFF.md) |
| Architecture | [`docs/architecture.md`](docs/architecture.md) |
| Coding agents | [`docs/coding-agents.md`](docs/coding-agents.md) |
| Module pages | [`docs/modules/`](docs/modules/) |
---
## Module map
| Package | Responsibility | Primary paths | Tests |
|---------|----------------|---------------|-------|
| `clm.models` | Pydantic domain types & config | `src/clm/models/` | `tests/unit/models/` (`test_config_thresholds.py`); manifest tests in `tests/unit/handoff/` |
| `clm.session` | Working session, Agent protocol, pure factory registry | `src/clm/session/` | `tests/unit/session/` (`test_tracking_session.py`, `test_live_event_capable.py`) |
| `clm.agents` | Vendor / coding-agent adapters + builtin registration | `src/clm/agents/` (`simulated`, `openai_compatible`, `grok/`) | `tests/unit/agents/` (simulated, openai, grok_*, factory, settings) |
| `clm.loop` | Outer control loop + live progress | `src/clm/loop/` | `tests/unit/loop/` |
| `clm.decision` | CONTINUE/COMPACT/HANDOFF rules + policies | `src/clm/decision/` | `tests/unit/decision/` |
| `clm.memory` | MemoryStore protocol, backends, backup | `src/clm/memory/` | `tests/unit/memory/` (incl. multi-agent + ab_memory) |
| `clm.compaction` | Verified compaction pipeline | `src/clm/compaction/` | `tests/unit/compaction/` |
| `clm.handoff` | Manifest + seed context pipeline | `src/clm/handoff/` | `tests/unit/handoff/` |
| `clm.metrics` | Token monitor, estimators, cost | `src/clm/metrics/` | `tests/unit/metrics/` (monitor, export, calibration) |
| `clm.cli` | Typer CLI (`clm …`) | `src/clm/cli/` | `tests/unit/cli/` |
| `clm.logging` | Audit JSONL + structured logs | `src/clm/logging/` | covered via `tests/unit/production/`, loop/metrics |
| `clm.export` | Metrics export (csv/json/html) | `src/clm/export/` | `tests/unit/metrics/test_export_metrics.py` |
| `clm.calibration` | Soft/hard threshold suggestions | `src/clm/calibration/` | `tests/unit/metrics/test_calibration.py` |
| `clm.experiments` | Offline A/B memory runner | `src/clm/experiments/` | `tests/unit/memory/test_ab_memory.py` |
| `clm.runtime` | Composition helpers (paths, config, script load) | `src/clm/runtime/` | `tests/unit/runtime/` |
**Also under unit:** `tests/unit/architecture/` (import boundaries), `tests/unit/support/` (fakes smoke), `tests/unit/production/` (cross-cutting P4), `tests/unit/test_version.py`.
**Integration:** `tests/integration/test_outer_loop_*.py`, `test_resume_run.py`.
**Layout (modularization step 9):** unit tests live under `tests/unit/<package>/` matching source domains. `clm.agents` holds vendor adapters; `clm.session` is vendor-free core + pure registry.
---
## Hard boundaries (do-not)
1. **`clm.loop` must not import vendor Grok modules**
Goal: loop talks only to `Agent` + capability protocols (`LiveEventCapable`, `ExternalSessionCapable`).
Live `doing=` labels: inject `describe_live_event` at the composition root (CLI wires `clm.agents.grok.stream.describe_stream_event` for grok-build).
**Known violations: EMPTY** — no `loop → clm.agents.grok*` / legacy `clm.session.grok_*` imports (import-boundary test).
2. **`clm.experiments` must not import `clm.cli`**
Goal: experiment runners use library APIs / shared helpers only.
**Fixed (step 4):** shared helpers live in `clm.runtime`; `ab_memory` imports `clm.runtime` only.
**Known violations: EMPTY** — no `experiments → cli` package edge.
3. **Session core vs vendor adapters**
- Core (vendor-free, `clm.session`): `agent.py`, `capabilities.py`, `working.py`, `tracking.py`, `factory.py` (registry only — no vendor imports).
- Adapters (`clm.agents`): `simulated.py`, `openai_compatible.py`, `grok/agent.py`, `grok/cli.py`, `grok/stream.py`; builtins via `register_builtin_agents()` (also on `import clm.agents`).
- Loop/decision/compaction/handoff/metrics must not `isinstance` concrete adapters.
- `session` must not import `agents` (adapters depend on session protocols, not the reverse).
4. **Never commit `.clm/`** — runtime artifacts only (sessions, memory, manifests, backups, exports).
5. **Always use the project venv**
```bash
.venv/bin/python …
.venv/bin/pytest …
```
System Python (e.g. 3.9) breaks typing / suite assumptions.
6. **Do not branch the outer loop on `agent_kind` or vendor names** — use registry + capabilities.
Import-boundary guard: `tests/unit/architecture/test_import_boundaries.py` — `KNOWN_PACKAGE_VIOLATIONS` and `KNOWN_MODULE_VIOLATIONS` are **empty** sets; do not re-grow them.
---
## Task routing (where to edit)
| Task | Start here | Also touch | Tests |
|------|------------|------------|-------|
| **Stream parse** (NDJSON / ACP session updates) | `agents/grok/stream.py` | `agents/grok/cli.py`, `agents/grok/agent.py` | `tests/unit/agents/test_grok_stream.py`, `test_grok_streaming_runner.py` |
| **Decision rule** (thresholds, phase, policies) | `decision/rules.py`, `decision/engine.py` | `decision/policy.py`, `models/config.py`, `models/decision.py` | `tests/unit/decision/`, `tests/unit/models/test_config_thresholds.py` |
| **Progress format** (human-readable STATUS lines) | `loop/progress.py` | `loop/control.py` (emit sites), `cli/run.py` (callback) | `tests/unit/loop/` |
| **New agent adapter** | `agents/<name>.py` + `agents/register.py` / `register_agent` | `models/config.py` (`AgentSettings.options`), docs `coding-agents.md` | `tests/unit/agents/` + optional `tests/integration/test_outer_loop_pluggable_agent.py` |
| **Verified compaction** | `compaction/pipeline.py`, `verify.py`, `summary.py` | `memory/*`, loop wire-up | `tests/unit/compaction/` |
| **Handoff / manifest** | `handoff/pipeline.py`, `handoff/seed.py` | `models/manifest.py`, schema `schemas/handoff_manifest.schema.json` | `tests/unit/handoff/` |
| **CLI flag / command** | `cli/app.py` + command module (`run.py`, …) | `cli/common.py` | `tests/unit/cli/` |
| **Memory backend** | `memory/base.py`, `factory.py`, store impl | `models/config.py` (`memory_backend`) | `tests/unit/memory/` |
---
## Verify commands
```bash
# Full suite (quiet)
.venv/bin/pytest -q
# Scoped by package folder
.venv/bin/pytest -q tests/unit/loop/
.venv/bin/pytest -q tests/unit/decision/
.venv/bin/pytest -q tests/unit/agents/test_grok_stream.py tests/unit/agents/test_grok_streaming_runner.py
.venv/bin/pytest -q tests/integration/
```
Prefer scoped runs while iterating; full suite before handoff/commit.
---
## Further reading
| Doc | Use when |
|-----|----------|
| [`docs/modules/*.md`](docs/modules/) | Module responsibility, API, deps, minimal file set |
| [`docs/architecture.md`](docs/architecture.md) | Dataflow, layers, artifact layout |
| [`docs/coding-agents.md`](docs/coding-agents.md) | Pluggable agents, Grok Build, CLI flags |
| [`docs/getting-started.md`](docs/getting-started.md) | Offline Simulated walkthrough |
| [`docs/manifest-schema.md`](docs/manifest-schema.md) | Handoff manifest fields |
| [`docs/metrics.md`](docs/metrics.md) | Token/cost observability |
| [`HANDOFF.md`](HANDOFF.md) | Branch tip, next safe action, open work |
---
## Conventions (short)
- English identifiers and docstrings in code; German OK in ops/handoff notes.
- Typed Python; package is typed (`py.typed`).
- Tests use fixtures under `tests/fixtures/`; inject fakes (e.g. `FakeGrokCliRunner`) — no live Grok in CI.
- Keep module docs in `docs/modules/` short; put deep design in `docs/architecture.md` / superpowers specs.
+100 -117
View File
@@ -5,11 +5,11 @@
| **Stand** | 2026-08-06 |
| **Version** | `0.4.0a1` |
| **Branch** | `main` |
| **HEAD (local = origin)** | `89186d6` — docs tip after Progress v2 |
| **Content tip (pushed)** | `812d493` — `feat(loop): live progress with heartbeat, turns, and goal-file` |
| **HEAD tip (may still match origin until commit)** | `b7db73a` — Live Progress v3 + Grok `streaming-json` |
| **Content on origin (before modularization work)** | `b7db73a` — Live Progress v3 + streaming |
| **Remote** | `http://10.27.27.139:3000/admin/Context-Lifecycle-Management-f-r-Agents.git` |
| **Tracking** | `main` → `origin/main` @ `89186d6` |
| **Status** | **Uncommitted local work** ready to commit: Live Progress **v3** + Grok **`streaming-json`** live events. Full suite **222 passed**. Threshold experiment **reverted** (coding soft/hard again 250k/350k on disk). |
| **Tracking** | `main` → `origin/main` |
| **Status** | **Local uncommitted modularization** (must-wave largely done). Suite ~**248 passed**. Optional remaining: live smoke, full ACP client, `.clm/config.toml`. Do **not** stage `.clm/`. |
---
@@ -17,67 +17,56 @@
Automatisiertes Context Lifecycle Management für LLM-Agents: Outer Control Loop entscheidet **CONTINUE / COMPACT / HANDOFF**, hält Context kontrolliert (Verified Compaction), schreibt Memory und erzeugt auditierbare Handoff-Manifeste — framework-agnostisch.
**On origin:** P0–P4, pluggable agents I0–I6, Grok Build (json one-shot), Live Progress **v2** + `--goal-file`.
**On origin:** P0–P4, pluggable agents I0–I6, Grok Build (json + streaming-json), Live Progress **v3** + `--goal-file`.
**Local (dirty):** Progress **v3** (run-state + workspace `doing=`) **plus** Grok **streaming-json** (ACP-derived NDJSON) → live mid-step `doing=` / STATUS.
**Local (dirty / modularization):** package split so session stays vendor-free, adapters live under `clm.agents`, composition helpers under `clm.runtime`, loop free of grok imports via `describe_live_event` injection.
---
## Working tree (uncommitted)
## Working tree (uncommitted modularization)
### Slice 1 — Live Progress v3
### Package layout (must-wave)
| Pfad | Rolle |
|------|--------|
| `src/clm/loop/progress.py` | Run-state suffixes, `doing=`, workspace mtime probe, heartbeat `refresh` |
| `src/clm/loop/control.py` | (shared with slice 2) thresholds/counters in context; rich compact/handoff emits |
| `tests/unit/test_progress.py` | Format + loop + workspace probe |
| Package | Role |
|---------|------|
| `clm.runtime` | Paths, session state I/O, `build_config` / `parse_profile`, `load_script` |
| `clm.agents` + `clm.agents.grok` | Vendor adapters: `simulated`, `openai_compatible`, `grok/{agent,cli,stream}` + `register_builtin_agents` |
| `clm.session` | Vendor-free core: `Agent`, capabilities, `WorkingSession` / tracking, pure factory registry |
| `clm.loop` | `OuterControlLoop` + progress; **no** vendor Grok imports |
| `clm.experiments` | A/B memory uses `clm.runtime` only (**not** `clm.cli`) |
| Docs | `AGENTS.md`, `docs/modules/*` (incl. `agents.md`, `runtime.md`), architecture / coding-agents |
| Tests | `tests/support` fakes; unit tree under `tests/unit/<domain>/` (concurrent reorg); import-boundary guard; suite ~248 passed |
### Slice 2 — Grok streaming-json / ACP live events
| Pfad | Rolle |
|------|--------|
| `src/clm/session/grok_stream.py` | **new** — parse / describe / aggregate NDJSON |
| `src/clm/session/grok_cli.py` | `stream` flag, line-streaming runner, Fake stream scripts |
| `src/clm/session/grok_build.py` | `LiveEventCapable`, default `stream=True` |
| `src/clm/session/capabilities.py` | `LiveEventCapable` + `LiveEventHandler` |
| `src/clm/session/factory.py` | `stream` via `agent.options` (`stream=false` disables) |
| `src/clm/loop/control.py` | Attach live sink during `step`; STATUS on tool/plan/thought; stream > mtime |
| `tests/unit/test_grok_stream.py` | **new** |
| `tests/unit/test_grok_streaming_runner.py` | **new** |
| `tests/unit/test_live_event_capable.py` | **new** |
| `tests/unit/test_loop_live_events.py` | **new** |
| `docs/coding-agents.md` | Streaming section |
| `docs/architecture.md` | Live progress + stream note |
| `HANDOFF.md` | this file |
### Design (local)
### Design (modularization)
```text
OuterControlLoop
→ if LiveEventCapable: set_live_event_handler
→ agent.step()
GrokBuildAgent (stream=True)
→ grok -p --output-format streaming-json
→ events: tool_call | plan | thought | text | usage | end
→ handler → live_state.doing / activity=agent_streaming
→ heartbeat refresh: doing = stream activity OR workspace mtime
→ detach handler
Composition root (CLI / library)
import clm.agents → register_builtin_agents()
build_agent(cfg, goal=…) → pure registry in clm.session.factory
OuterControlLoop(
…,
describe_live_event=describe_stream_event # only for grok-build; injected
)
clm.session → models only (no agents)
clm.agents → models + session protocols
clm.runtime → models only
clm.experiments → runtime (+ loop stack); NOT cli
clm.loop → Agent + capabilities; NOT clm.agents.grok*
```
- **Default Grok:** `streaming-json` (ACP session-update shape).
- **Disable:** `--agent-option stream=false` → classic one-shot `json`.
- **Not implemented:** full bidirectional ACP client (`grok agent` JSON-RPC), permission mediation over ACP.
- **Builtins:** `import clm.agents` (or `register_builtin_agents()`) before `build_agent`.
- **Live `doing=`:** CLI wires `clm.agents.grok.stream.describe_stream_event` into loop.
- **Import boundaries:** `KNOWN_PACKAGE_VIOLATIONS` and loop vendor module violations **empty**.
- **Do not commit:** `.clm/` runtime.
### Example progress (stream)
### Example progress (stream — already on origin baseline)
```text
>>> clm run start session_id=… agent=grok-build soft=250000 hard=350000 max_steps=10
step=1/10 phase=agent activity=waiting_for_agent doing=starting agent subprocess …
status … activity=agent_streaming doing=tool read_file: src/App.tsx
status … activity=agent_streaming doing=plan: implement feature
status … activity=agent_streaming doing=thinking: …
step=1/10 phase=done … turns=… tools=… notes=…; stream=acp
<<< clm run end …
```
@@ -92,40 +81,22 @@ step=1/10 phase=done … turns=… tools=… notes=…; stream=acp
cd /Volumes/SSD2TB/books/clm-agents
source .venv/bin/activate # always .venv (system Python 3.9 breaks typing)
git status
.venv/bin/pytest -q # expect 222 passed
.venv/bin/pytest -q # expect ~248 passed (may drift during concurrent test reorg)
```
### 2. Commit (recommended two slices)
### 2. Commit modularization (when ready)
Logical slices (do **not** stage `.clm/`):
```bash
# Slice 1 — Progress v3 (without grok_stream / new stream tests if splitting carefully)
# Prefer logical commits:
git add src/clm/loop/progress.py tests/unit/test_progress.py
# + relevant control.py parts if split is hard → one combined commit is OK
git add \
src/clm/session/grok_stream.py \
src/clm/session/grok_cli.py \
src/clm/session/grok_build.py \
src/clm/session/capabilities.py \
src/clm/session/factory.py \
src/clm/loop/control.py \
tests/unit/test_grok_stream.py \
tests/unit/test_grok_streaming_runner.py \
tests/unit/test_live_event_capable.py \
tests/unit/test_loop_live_events.py \
docs/coding-agents.md \
docs/architecture.md \
HANDOFF.md
# Suggested messages:
# feat(loop): live progress v3 run-state and workspace doing=
# feat(session): Grok streaming-json ACP live events
# (or one combined feat if split is messy)
# runtime + experiments inversion
# agents package + session harden
# loop describe_live_event injection
# docs (AGENTS.md, docs/modules/*, architecture, coding-agents, HANDOFF)
# tests/support + import boundaries
```
Do **not** stage `.clm/`.
Prefer architecture-level commit messages over brittle test-path lists (test tree may still be moving).
### 3. After commit (optional)
@@ -154,13 +125,16 @@ Do **not** stage `.clm/`.
| Default agent | `simulated` + `--script` |
| External session | `ExternalSessionCapable` + `external_session_id` |
| Live mid-step | `LiveEventCapable` + Grok `streaming-json` (ACP-shaped NDJSON) |
| Stream labels | Inject `describe_live_event` at composition root (not loop→grok import) |
| Adapters package | `clm.agents` (+ `clm.agents.grok`); session is core only |
| Composition helpers | `clm.runtime`; experiments must not import CLI |
| Compact | CLM pipeline + `inject_context` (not Grok `/compact`) |
| Handoff | Manifest + unbind external + seed |
| Completion | `CLM_TASK_STATUS: completed` / `failed` in agent text |
| YOLO | Opt-in |
| CI | Fake runners only; no live `grok` |
| Goal input | `--goal` **or** `--goal-file` |
| Thresholds | coding soft/hard **250k / 350k** (mid-band; no local experiment) |
| Thresholds | coding soft/hard **250k / 350k** (mid-band) |
| Full ACP JSON-RPC client | **later** (Phase B registry kind) |
### CLI contract
@@ -189,51 +163,57 @@ clm run --goal-file ./goal.md --script tests/fixtures/sample_transcript.json
|----------|------|
| Coding agents + stream | `docs/coding-agents.md` |
| Architecture | `docs/architecture.md` |
| Module map / boundaries | `AGENTS.md`, `docs/modules/` |
| Design P0–P4 | `docs/superpowers/specs/2026-08-04-clm-design.md` |
| Agents plan | `docs/superpowers/plans/2026-08-05-configurable-coding-agents.md` |
| Progress | `src/clm/loop/progress.py` |
| Outer loop | `src/clm/loop/control.py` |
| Stream parse | `src/clm/session/grok_stream.py` |
| Grok runner / agent | `grok_cli.py`, `grok_build.py` |
| Stream parse | `src/clm/agents/grok/stream.py` |
| Grok runner / agent | `src/clm/agents/grok/cli.py`, `agent.py` |
| Builtin registration | `src/clm/agents/register.py` |
| Session core | `src/clm/session/` (protocol, capabilities, factory registry, working/tracking) |
| Runtime helpers | `src/clm/runtime/` |
| Capabilities | `src/clm/session/capabilities.py` |
| Config | `src/clm/models/config.py` |
| SDD scratch | `.superpowers/sdd/grok-build-streaming/` (gitignored-ish scratch) |
| Modularization SDD | `.superpowers/sdd/agent-modularization/` |
---
## Done
### On origin (through `812d493` / tip docs `89186d6`)
### On origin (through `b7db73a`)
- [x] P0–P4
- [x] Pluggable agents I0–I6 + Grok Build (json)
- [x] Live Progress **v2** + `--goal-file` + `--quiet`
### Local complete (uncommitted)
- [x] Live Progress **v3** (run-state, workspace `doing=`, one-line STATUS)
- [x] `LiveEventCapable`
- [x] Grok `streaming-json` parse + stream runner + agent default on
- [x] Loop attach → live STATUS / `activity=agent_streaming`
- [x] Unit/integration tests for stream path
- [x] Docs coding-agents + architecture
- [x] Threshold experiment reverted
- [x] **Full suite: 222 passed**
- [x] `LiveEventCapable` + Grok `streaming-json` live events
- [x] Threshold experiment reverted (coding 250k/350k)
### Local modularization (uncommitted; must-wave largely done)
- [x] `clm.runtime` (paths, script_loader, config_build); experiments → runtime not cli
- [x] `clm.agents` + `clm.agents.grok`; adapters moved out of session
- [x] Session core vendor-free; pure factory registry
- [x] OuterControlLoop `describe_live_event` injection (no grok import in loop)
- [x] `AGENTS.md` + `docs/modules/*` (agents, runtime, …)
- [x] `tests/support` fakes + import boundary tests
- [x] Known package / loop-vendor violations **empty**
- [x] Suite ~**248 passed** (confirm with `.venv/bin/pytest -q`; concurrent test reorg may shift paths/counts)
---
## Verified
```bash
.venv/bin/pytest -q
# 222 passed
.venv/bin/python -c "import clm; print(clm.__version__)"
# 0.4.0a1
.venv/bin/ruff check src/clm/session/grok_stream.py src/clm/session/grok_cli.py \
src/clm/session/grok_build.py src/clm/session/capabilities.py \
src/clm/session/factory.py src/clm/loop/control.py \
tests/unit/test_grok_stream.py tests/unit/test_grok_streaming_runner.py \
tests/unit/test_live_event_capable.py tests/unit/test_loop_live_events.py
# clean
.venv/bin/pytest -q
# ~248 passed (see modularization task reports; re-run after concurrent test moves)
# Import boundaries (architecture guard)
.venv/bin/pytest -q tests/unit/architecture/test_import_boundaries.py
```
## Not verified / out of scope
@@ -242,7 +222,7 @@ clm run --goal-file ./goal.md --script tests/fixtures/sample_transcript.json
- Full ACP client (`grok agent` stdio/WebSocket)
- Live Mem0 / OpenAI; Auto-Apply Calibrate
- `.clm/config.toml`
- Commit + push of local dirty work
- Commit + push of local modularization work
---
@@ -254,27 +234,28 @@ clm run --goal-file ./goal.md --script tests/fixtures/sample_transcript.json
| Grok auto-compact ~85% | Can race CLM COMPACT |
| Stream quality | Depends on Grok emitting tool/plan/thought lines |
| Permissions | Headless still needs `--yolo` / permission mode (not ACP request_permission) |
| Registry global | Tests must snapshot/restore `_REGISTRY` |
| Registry global | Tests must snapshot/restore `_REGISTRY`; builtins via `import clm.agents` |
| Concurrent test reorg | Prefer architecture descriptions over hard-coded test paths in docs |
---
## Blockers
None. Ready to commit local work.
None for modularization must-wave. Optional ops: live smoke, ACP, config.toml. Ready to commit when test reorg settles.
---
## Quick resume checklist
- [x] I0–I6 on origin
- [x] Progress v2 on origin
- [x] Progress v3 implemented locally
- [x] Grok streaming-json live events implemented locally
- [x] 222 tests green; thresholds clean
- [x] HANDOFF refreshed (2026-08-06)
- [ ] Commit (+ optional push)
- [x] I0–I6 + Progress v3 + streaming on origin baseline (`b7db73a`)
- [x] `clm.runtime` + experiments↔cli inversion
- [x] `clm.agents` / session harden + loop injection
- [x] Import boundaries empty for experiments→cli and loop→grok
- [x] Docs modules + AGENTS map
- [x] Suite ~248 green (re-confirm after concurrent moves)
- [ ] Commit modularization (+ optional push) — do not stage `.clm/`
- [ ] Optional live smoke `grok-build`
- [ ] **Not:** `.clm/` in git; live grok in CI
- [ ] **Not:** `.clm/` in git; live grok in CI; full ACP yet
---
@@ -282,20 +263,22 @@ None. Ready to commit local work.
```text
Repo: /Volumes/SSD2TB/books/clm-agents
Branch: main @ 89186d6 (== origin/main)
Branch: main @ b7db73a tip (until modularization commit)
HANDOFF.md — Stand 2026-08-06
On origin: P0–P4, I0–I6, Progress v2 + goal-file, Grok json-only.
On origin: P0–P4, I0–I6, Progress v3, Grok streaming-json.
Local uncommitted (222 tests green):
- Live Progress v3 (progress.py + control + test_progress)
- Grok streaming-json / LiveEventCapable (grok_stream, grok_cli, grok_build,
capabilities, factory, control attach, 4 new test modules, docs)
Local uncommitted modularization (must-wave largely done, ~248 tests):
- clm.runtime (paths, config_build, script_loader)
- clm.agents + grok adapters; session vendor-free
- OuterControlLoop.describe_live_event injection
- experiments → runtime (not cli)
- AGENTS.md + docs/modules; tests/support + import boundaries
Next:
1) Commit (1–2 slices) — do not stage .clm/
2) Optional push
3) Optional live smoke: clm run --agent grok-build … (watch doing= tools)
1) Re-run .venv/bin/pytest -q after any concurrent test reorg
2) Commit modularization slices — do not stage .clm/
3) Optional push / live smoke: clm run --agent grok-build …
4) Later: full ACP client kind; config.toml
Always: .venv/bin/python | .venv/bin/pytest
+1 -1
View File
@@ -123,7 +123,7 @@ clm --help
### OpenAI-compatible adapter (library)
```python
from clm.session.openai_compatible import OpenAICompatibleAgent
from clm.agents.openai_compatible import OpenAICompatibleAgent
agent = OpenAICompatibleAgent(
api_key="sk-…",
+25 -8
View File
@@ -56,7 +56,8 @@ CLM ist ein **framework-agnostischer Outer Control Loop** über einem ausführen
| Schicht | Module (Ziel) | Verantwortung |
|---------|---------------|---------------|
| Outer Control Loop | `clm.loop.control`, `clm.decision.*`, `clm.metrics.*`, `clm.compaction.*`, `clm.handoff.*` | Orchestrierung, Metriken, Entscheidung, Pipelines |
| Working Session | `clm.session.working`, `clm.session.agent`, `clm.session.simulated` | Agent-Ausführung, Context Window, inject/reset |
| Working Session | `clm.session` (core) + `clm.agents.*` (adapters) | Protocol/session state vs. vendor adapters; inject/reset |
| Composition | `clm.runtime` | Paths, config build, script load (shared by CLI + experiments) |
| Long-Term Memory | `clm.memory.base`, `clm.memory.file_store`, `namespaced`, `backup` | Persistente Keys/Inhalte; Multi-Agent-Namespace; Backup |
| Phase-Orchestration (P3) | `clm signal`, HITL confirm | `PHASE_BOUNDARY`, Confirm-Policy, Export/Calibrate |
| Production & Experiments (P4) | `logging.structured`, `metrics.cost`, `experiments`, `decision.policy` | Structured logs, Cost model, A/B Memory, Policy-Plugins |
@@ -75,7 +76,11 @@ inject_context(system_addon)
reset_with_seed(seed_messages)
```
MVP: `SimulatedAgent` mit deterministischen Outputs und Token-Zähler. Echte Adapter ab P2+.
Protocol und optionale Capabilities (`ExternalSessionCapable`, `LiveEventCapable`)
leben in **`clm.session`** (vendor-free). Konkrete Adapter implementieren das
Protocol unter **`clm.agents`** (siehe unten). Factory-Registry
(`build_agent` / `register_agent`) ist rein und importiert keine Vendor-Module;
Builtins registrieren sich via `import clm.agents` → `register_builtin_agents()`.
### `WorkingSession`
@@ -155,7 +160,7 @@ Bindet Monitor, Engine, Compaction, Handoff und Session zusammen.
- Terminal-Status (`completed`/`failed`) nach Step → **keine** Compact/Handoff-Pipeline.
- COMPACT mit Verify-Fail → Override-Decision `trigger=verify_fail` → HandoffPipeline.
- Token-Basis: default session-kumulativ; optional `use_context_tokens_for_thresholds` (Window-Größe).
- **Live Progress:** optionaler `progress_callback` am Loop (CLI default an, `--quiet` aus) — Events: `run_start`, `step_start` (vor Agent, inkl. `max_turns`), `status` (Heartbeat alle 20 s während Agent/Compact/Handoff; **einzeilig** + event-driven ticks), `step` (nach Decision: `phase=done`, `turns`/`tools`/`notes`/`summary`), `compact_start`/`compact`, `handoff_start`/`handoff`, `confirm`, `run_end`. Mit `--max-steps`: `step=i/max` und `open=N` (`clm.loop.progress`). Run-State: `tokens.total=` + `soft=`/`hard=` + `compacts=`/`handoffs=`; `activity=` (`waiting_for_agent` | `agent_streaming` | `running_compaction` | `running_handoff`); optional `last_decision=` / `external_session=`. Während Agent-Phase: `doing=` bevorzugt aus **Live-Events** (`LiveEventCapable` + Grok `streaming-json` / ACP session updates: tools, plan, thought, text); Fallback Workspace-mtime. `goal=`/`cwd=`/`detail=` nur auf `step_start`, nicht auf jedem Heartbeat.
- **Live Progress:** optionaler `progress_callback` am Loop (CLI default an, `--quiet` aus) — Events: `run_start`, `step_start` (vor Agent, inkl. `max_turns`), `status` (Heartbeat alle 20 s während Agent/Compact/Handoff; **einzeilig** + event-driven ticks), `step` (nach Decision: `phase=done`, `turns`/`tools`/`notes`/`summary`), `compact_start`/`compact`, `handoff_start`/`handoff`, `confirm`, `run_end`. Mit `--max-steps`: `step=i/max` und `open=N` (`clm.loop.progress`). Run-State: `tokens.total=` + `soft=`/`hard=` + `compacts=`/`handoffs=`; `activity=` (`waiting_for_agent` | `agent_streaming` | `running_compaction` | `running_handoff`); optional `last_decision=` / `external_session=`. Während Agent-Phase: `doing=` bevorzugt aus **Live-Events** (`LiveEventCapable` + optional injected `describe_live_event`; CLI wires `clm.agents.grok.stream.describe_stream_event` for grok-build — **loop does not import grok**); Fallback Workspace-mtime. `goal=`/`cwd=`/`detail=` nur auf `step_start`, nicht auf jedem Heartbeat.
**P3 Loop- / CLI-Erweiterungen:**
@@ -172,12 +177,24 @@ Bindet Monitor, Engine, Compaction, Handoff und Session zusammen.
- **A/B Memory:** `clm experiment ab-memory --script … --backends file,mem0,hybrid` → Report (completion, tokens, recall, interventions).
- **Policy plugins:** `DecisionEngine(..., policies=[…])` — z. B. `BlockersForceHandoffPolicy`.
### Agent-Adapter
### Working Session / Agent-Adapter
| Adapter | Zweck |
|---------|--------|
| `SimulatedAgent` | Deterministische Scripts; `script_offset` für Resume |
| `OpenAICompatibleAgent` | PoC: `/v1/chat/completions` (stdlib HTTP, inject `http_post` für Tests) |
**Session core** (`clm.session`) is vendor-free: `Agent` protocol, capabilities,
`WorkingSession` / `TrackingWorkingSession`, pure factory registry.
**Adapters** live in **`clm.agents`** (not under `session`):
| Adapter | Package path | Zweck |
|---------|--------------|--------|
| `SimulatedAgent` | `clm.agents.simulated` | Deterministische Scripts; `script_offset` für Resume |
| `GrokBuildAgent` | `clm.agents.grok` | Live headless Grok Build; default `streaming-json` |
| `OpenAICompatibleAgent` | `clm.agents.openai_compatible` | PoC: `/v1/chat/completions` (stdlib HTTP, inject `http_post` für Tests) |
Composition root (`clm.cli`) registers builtins with `import clm.agents` and, for
grok-build, injects `describe_stream_event` as `OuterControlLoop.describe_live_event`.
Loop/decision/compaction/handoff must not `isinstance` concrete adapters.
See [`docs/coding-agents.md`](coding-agents.md) and [`docs/modules/agents.md`](modules/agents.md).
---
+28 -13
View File
@@ -5,14 +5,15 @@ branches on vendor names: it talks to the framework-agnostic `Agent` protocol
(`step` / `inject_context` / `reset_with_seed`) and optional capabilities such as
`ExternalSessionCapable` for dual session IDs.
| Layer | Role | Vendor-free? |
|-------|------|--------------|
| Protocol `Agent` | Execution surface | yes |
| Capabilities (e.g. `ExternalSessionCapable`) | Cross-cutting optional features | yes |
| Config `agent_kind` + `AgentSettings` | Selection & parameters | yes (keys generic) |
| Factory / registry `build_agent` | Kind → instance | yes |
| Adapter impl | Grok / Simulated / … | no (per package) |
| CLI | `--agent` + generic flags + escape hatch | thin mapping |
| Layer | Role | Vendor-free? | Package |
|-------|------|--------------|---------|
| Protocol `Agent` | Execution surface | yes | `clm.session` |
| Capabilities (e.g. `ExternalSessionCapable`, `LiveEventCapable`) | Cross-cutting optional features | yes | `clm.session` |
| Config `agent_kind` + `AgentSettings` | Selection & parameters | yes (keys generic) | `clm.models` |
| Factory / registry `build_agent` | Kind → instance (no vendor imports) | yes | `clm.session.factory` |
| Builtin registration | `register_builtin_agents()` on import | — | `clm.agents` |
| Adapter impl | Grok / Simulated / OpenAI-compatible | no | `clm.agents` (+ `clm.agents.grok`) |
| CLI | `--agent` + generic flags + escape hatch | thin mapping | `clm.cli` |
Default offline agent: **`simulated`** (JSON script). First live adapter:
**`grok-build`** (headless Grok Build CLI).
@@ -58,9 +59,17 @@ clm run --agent grok-build --goal-file ./GOAL.md \
### Library / config
**Always** import `clm.agents` (or call `register_builtin_agents()`) **before**
`build_agent` so builtins (`simulated`, `grok-build`) are on the registry.
Session factory itself does not side-effect-register vendors.
```python
from pathlib import Path
from clm.models.config import AgentSettings, ClmConfig
import clm.agents # registers simulated + grok-build (idempotent)
from clm.session.factory import build_agent
# or: from clm.agents import GrokBuildAgent, SimulatedAgent, register_builtin_agents
cfg = ClmConfig(
agent_kind="grok-build",
@@ -73,7 +82,9 @@ cfg = ClmConfig(
),
)
agent = build_agent(cfg, goal="Implement feature X")
# Wire TrackingWorkingSession + OuterControlLoop (see clm.cli.run)
# Wire TrackingWorkingSession + OuterControlLoop (see clm.cli.run).
# For live doing= labels with grok-build, pass describe_live_event=
# clm.agents.grok.stream.describe_stream_event into OuterControlLoop.
```
Unknown `agent_kind` raises `ValueError` listing registered keys.
@@ -152,9 +163,10 @@ NDJSON lines derived from the agent’s **native ACP session updates** (`tool_ca
| Layer | Role |
|-------|------|
| `LiveEventCapable` | Vendor-free sink: `set_live_event_handler` |
| `GrokBuildAgent` | Streams when `stream=True` (default) or a sink is attached |
| `OuterControlLoop` | Attaches sink during `agent.step`; maps events → `doing=` / STATUS ticks |
| `LiveEventCapable` | Vendor-free sink on session: `set_live_event_handler` |
| `GrokBuildAgent` (`clm.agents.grok`) | Streams when `stream=True` (default) or a sink is attached |
| `OuterControlLoop` | Attaches sink during `agent.step`; optional injected `describe_live_event` → `doing=` / STATUS ticks |
| Composition root (CLI) | For `grok-build`, wires `clm.agents.grok.stream.describe_stream_event` (loop never imports grok) |
| Fallback | Workspace mtime probe if no stream activity yet |
Disable streaming (one-shot `json` only):
@@ -229,4 +241,7 @@ for that shrink.
- Implementation plan: [`docs/superpowers/plans/2026-08-05-configurable-coding-agents.md`](superpowers/plans/2026-08-05-configurable-coding-agents.md)
- Getting started (offline Simulated): [`docs/getting-started.md`](getting-started.md)
- Architecture: [`docs/architecture.md`](architecture.md)
- Source: `src/clm/session/factory.py`, `capabilities.py`, `grok_build.py`, `grok_cli.py`, `tracking.py`
- Module pages: [`docs/modules/agents.md`](modules/agents.md), [`docs/modules/session.md`](modules/session.md)
- Source: session core `src/clm/session/` (protocol, capabilities, factory, working/tracking);
adapters `src/clm/agents/` (`simulated`, `openai_compatible`, `grok/*`);
composition helpers `src/clm/runtime/`
+1 -1
View File
@@ -92,7 +92,7 @@ Pipeline validiert strikt **vor** Write. Seed-Context enthält kompakte Manifest
|----------|------|
| JSON Schema | `schemas/handoff_manifest.schema.json` |
| Pydantic | `src/clm/models/manifest.py` |
| Tests | `tests/unit/test_models_manifest.py` |
| Tests | `tests/unit/handoff/test_models_manifest.py` |
| Fixture | `tests/fixtures/sample_manifest.json` |
Dieses Dokument bleibt die Intent-Referenz; bei Schema-Änderungen Sync halten.
+60
View File
@@ -0,0 +1,60 @@
# clm.agents
## Responsibility
Vendor / coding-agent **adapters** that implement the framework-agnostic
`Agent` protocol (and optional capabilities). Built-in factory builders for
`simulated` and `grok-build` live here so `clm.session` stays vendor-free.
Importing `clm.agents` calls `register_builtin_agents()` (idempotent).
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `SimulatedAgent`, `ScriptSource` | `simulated.py` | Offline JSON / callable script agent |
| `GrokBuildAgent`, `parse_clm_task_status` | `grok/agent.py` | Live Grok Build headless adapter |
| `GrokCli*`, `FakeGrokCliRunner`, `SubprocessGrokCliRunner` | `grok/cli.py` | Headless CLI runner + fakes |
| `describe_stream_event`, stream parsers | `grok/stream.py` | `streaming-json` NDJSON helpers |
| `OpenAICompatibleAgent` | `openai_compatible.py` | PoC `/v1/chat/completions` |
| `register_builtin_agents` | `register.py` | Register default kinds on session factory |
```python
from clm.agents import SimulatedAgent, GrokBuildAgent, register_builtin_agents
from clm.agents.grok.stream import describe_stream_event
from clm.session.factory import build_agent
register_builtin_agents() # also done on import of clm.agents
agent = build_agent(cfg, goal="…")
```
## Depends on
- `clm.models` (session DTOs, config)
- `clm.session` (protocols / capabilities only — not adapter shims)
## Must not depend on
- `clm.loop`, `clm.decision`, `clm.compaction`, `clm.handoff`, `clm.cli`
- Must not import through temporary session re-export shims (hard move: none)
## Tests
**Location:** `tests/unit/agents/` — `test_simulated_agent.py`, `test_grok_build_*.py`,
`test_grok_cli.py`, `test_grok_stream.py`, `test_grok_streaming_runner.py`,
`test_openai_compatible_agent.py`, `test_agent_factory.py`, `test_agent_settings.py`.
**Integration:** `tests/integration/test_outer_loop_pluggable_agent.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| New adapter | new `agents/<kind>.py` + `register.py` builder + `docs/coding-agents.md` |
| Stream parse | `grok/stream.py` (+ runner wiring in `grok/cli.py`) |
| Status marker | `grok/agent.parse_clm_task_status` |
**Do not** teach `OuterControlLoop` about concrete adapter classes; use protocols.
Live `doing=` labels: CLI injects `describe_stream_event` as
`OuterControlLoop.describe_live_event` — adapters never need a loop import.
See [`docs/coding-agents.md`](../coding-agents.md).
+61
View File
@@ -0,0 +1,61 @@
# clm.cli
## Responsibility
User-facing **Typer** entrypoint (`clm …`): layout init, run/resume outer loop,
one-shot compact/handoff/decide/status/signal, calibrate, export metrics, memory
backup/restore, and experiment commands. Thin mapping from flags → library APIs;
no business rules that belong in `loop` / `decision`.
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `app` | `app.py` | Typer application (console script entry); `import clm.agents` for builtins |
| `run_session` | `run.py` | Wire config → agent → session → `OuterControlLoop`; inject `describe_live_event` for grok-build |
| `make_progress_callback`, `make_confirm_callback` | `run.py` | HITL / progress wiring |
| Path/state helpers | `common.py` | Re-exports `clm.runtime` + CLI-only helpers (`resolve_goal`, `parse_agent_options`, …) |
| Command runners | `compact.py`, `handoff.py`, `status.py`, `signal.py`, `calibrate.py`, `export_cmd.py`, `backup_cmd.py`, `experiment.py` | Subcommand implementations |
**Commands (high level):** `init`, `run`, `decide`, `compact`, `handoff`, `status`,
`signal`, `calibrate`, `export metrics`, `backup memory|restore`,
`experiment ab-memory`.
Agent selection flags are documented in [`docs/coding-agents.md`](../coding-agents.md).
Composition helpers live in [`clm.runtime`](runtime.md); script load is
`clm.runtime.load_script` (not CLI-owned).
## Depends on
- Most library packages: `loop`, `session`, `agents`, `runtime`, `decision`,
`compaction`, `handoff`, `memory`, `metrics`, `models`, `export`,
`calibration`, `experiments`.
- Registers built-ins via `import clm.agents` at composition root.
## Must not depend on
- Nothing “below” CLI is forbidden, but **library packages must not import CLI**.
**Fixed:** `clm.experiments` uses `clm.runtime` only (no `clm.cli` imports).
- CLI should not embed decision priority or compaction verify logic; call engines/pipelines.
Vendor stream labels: CLI injects `describe_stream_event` into
`OuterControlLoop.describe_live_event` — loop stays free of grok imports.
## Tests
**Location:** `tests/unit/cli/` — `test_cli_smoke.py`, `test_cli_agent_selection.py`,
`test_cli_p4.py`, `test_resolve_goal.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| New flag on `clm run` | `app.py` + `runtime.config_build` / `run.py` |
| Agent option parsing | `common.parse_agent_options` |
| Progress on/off | `run.py` (`--quiet` vs default callback) |
| Goal from file | `common.resolve_goal` |
| Offline experiment CLI | `experiment.py` → `experiments.ab_memory` |
| Live stream labels | `run.py` injects `clm.agents.grok.stream.describe_stream_event` |
Always document user-facing flags in coding-agents / getting-started when adding
them. Prefer library tests over only CLI smoke for core behavior.
+52
View File
@@ -0,0 +1,52 @@
# clm.compaction
## Responsibility
**Verified compaction:** build a structured summary and compact hint from
`SessionState`, snapshot into memory, optionally **verify recall** before mutating
the message window. On verify failure, leave messages intact and signal the loop
to fall back to HANDOFF (`fell_back_to_handoff`).
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `CompactionPipeline` | `pipeline.py` | `run(state) → CompactResult` |
| `CompactResult` | `pipeline.py` | Hint, summary, keys, scores, `fell_back_to_handoff` |
| `build_structured_summary`, `build_compact_hint` | `summary.py` | Deterministic Markdown builders (no LLM) |
| `VerificationSuite`, `VerifyResult` | `verify.py` | Q/A recall score against summary+hint |
Config (from `ClmConfig`): `verify_compaction` (default True),
`recall_score_threshold` (default 0.8), `retain_recent_messages`.
Pipeline order: summary → memory write → **verify** → mutate messages only if pass
(system hint + recent user/assistant tail); set `last_compact_step`.
## Depends on
- `clm.models` (session, config, messages)
- `clm.memory.base.MemoryStore`
## Must not depend on
- `clm.loop`, `clm.session` adapters, `clm.cli`, `clm.handoff`
- Vendor LLM summarizers as default (deterministic summary is intentional)
## Tests
**Location:** `tests/unit/compaction/` — `test_compaction_summary.py`,
`test_compaction_verify_pipeline.py`, `test_verification_suite.py`.
**Integration:** `test_outer_loop_compact.py`, `test_outer_loop_verify_fail_handoff.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| Hint/summary content | `summary.py` |
| Verify questions / scoring | `verify.py` |
| Retain-tail / mutate policy | `pipeline.py` |
| Threshold defaults | `models/config.py` |
Loop responsibility after `fell_back_to_handoff=True`: override decision
`trigger=verify_fail` and run `HandoffPipeline` — not inside this package.
+53
View File
@@ -0,0 +1,53 @@
# clm.decision
## Responsibility
Rule-based engine that maps `SessionState` + `TokenSnapshot` (+ config) to a
`Decision` with action **CONTINUE | COMPACT | HANDOFF**, rationale, and trigger.
Optional **DecisionPolicy** plugins can override the base decision.
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `DecisionEngine` | `engine.py` | `decide(state, metrics) → Decision` |
| `DecisionPolicy` | `policy.py` | Protocol: `maybe_override(...) → Decision \| None` |
| `BlockersForceHandoffPolicy` | `policy.py` | Example: blockers → HANDOFF |
| `PreferHandoffOverCompactPolicy` | `policy.py` | Example: force handoff over compact |
| `may_compact`, `steps_since_last_compact` | `rules.py` | Soft-compact gating helpers |
**Base priority (engine):**
1. `phase_boundary` + `prefer_handoff_on_phase` → HANDOFF
2. tokens ≥ `hard_handoff` → HANDOFF
3. tokens ≥ `soft_compact` and `may_compact` → COMPACT
4. else CONTINUE
Token basis: session cumulative total, or `context_tokens` when
`use_context_tokens_for_thresholds` is set. Policies run after base; first non-None
override wins (applied in list order).
## Depends on
- `clm.models` (`ClmConfig`, `Decision`, `SessionState`, `TokenSnapshot`, `TaskStatus`).
## Must not depend on
- `clm.loop`, `clm.session`, `clm.cli`, memory/compaction/handoff pipelines.
- Decision is pure evaluation; side effects (audit, pipelines) live in the loop.
## Tests
**Location:** `tests/unit/decision/` — `test_decision_rules.py`, `test_decision_policy.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| Change soft/hard / phase rule | `decision/engine.py`, optionally `rules.py` |
| Min steps between compacts | `rules.py` + `models/config.py` |
| New policy plugin | `policy.py` (or external) + pass into `DecisionEngine(..., policies=[…])` |
| New trigger string | keep free-form `Decision.trigger`; document in architecture if user-facing |
Do not embed compaction/handoff I/O here. Degradation-signal COMPACT is reserved
(stub in architecture; not a separate engine branch yet).
+48
View File
@@ -0,0 +1,48 @@
# clm.handoff
## Responsibility
Session transition pipeline: build a **schema-valid `HandoffManifest`**, persist it
(under `.clm/manifests/` and session handoffs), update memory, and produce
**seed messages** for `agent.reset_with_seed` (manifest summary + goal +
next_safe_action + memory hits — not the full transcript).
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `HandoffPipeline` | `pipeline.py` | `run(state, decision) → HandoffResult` |
| `HandoffResult` | `pipeline.py` | Manifest path(s), seed messages, keys written |
| `build_seed_messages` | `seed.py` | Seed context from manifest + memory |
| `derive_next_safe_action`, `map_task_status` | `pipeline.py` | Helpers for blockers / status mapping |
Manifest model: `clm.models.manifest.HandoffManifest` (`schema_version`, optional
`agent_id`, …). JSON Schema: `schemas/handoff_manifest.schema.json`.
## Depends on
- `clm.models` (manifest, session, decision, config)
- `clm.memory.base.MemoryStore`
## Must not depend on
- `clm.loop`, `clm.session` adapters, `clm.cli`, `clm.compaction`
- Vendor session stores (Grok external id clearing is loop/session concern)
## Tests
**Location:** `tests/unit/handoff/` — `test_handoff_pipeline.py`, `test_models_manifest.py`.
**Integration:** `test_outer_loop_handoff.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| Manifest fields / validation | `models/manifest.py`, schema JSON, `pipeline.py` |
| Seed text shape | `seed.py` |
| next_safe_action defaults | `pipeline.derive_next_safe_action` |
| CLI one-shot handoff | `cli/handoff.py` (thin wrapper) |
Docs: [`docs/manifest-schema.md`](../manifest-schema.md). MVP typically stops the
loop after handoff (`continue_after_handoff=False`).
+55
View File
@@ -0,0 +1,55 @@
# clm.loop
## Responsibility
Outer control loop orchestration: **step → metrics → decide → audit → CONTINUE |
COMPACT | HANDOFF**, with HITL confirm, live progress events, heartbeats, and
optional live-event sink attachment for streaming agents.
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `OuterControlLoop` | `control.py` | `run(max_steps=None) → LoopResult`; wires session, monitor, engine, pipelines |
| `LoopResult` | `control.py` | Final state, decisions, handoffs, compact_count, stopped_reason, … |
| `ProgressEvent`, `ProgressKind` | `progress.py` | Structured progress payload |
| `format_progress` | `progress.py` | Human-readable one-line (and multi-line) formatting |
| `ProgressCallback` | `progress.py` | `Callable[[ProgressEvent], None]` |
| Helpers | `progress.py` | `workspace_recent_files`, `describe_workspace_activity`, `assistant_summary`, `ProgressHeartbeat` |
Progress kinds: `run_start`, `step_start`, `step`, `status`, `confirm`,
`compact_start` / `compact`, `handoff_start` / `handoff`, `run_end`.
## Depends on
- `clm.models`, `clm.decision`, `clm.metrics`, `clm.compaction`, `clm.handoff`
- `clm.logging.audit` (decision audit lines)
- `clm.session.working`, `clm.session.capabilities` (protocols only)
## Must not depend on
- **Vendor adapters** (`grok_build`, `grok_cli`, `grok_stream`, `openai_compatible`, …)
- `clm.cli` (CLI owns callbacks; loop stays library-clean)
Live `doing=` labels use an optional injected ``describe_live_event`` callable
(composition root / CLI wires ``clm.agents.grok.stream.describe_stream_event``
for grok-build).
## Tests
**Location:** `tests/unit/loop/` — `test_progress.py`, `test_loop_live_events.py`,
`test_loop_polish.py`, `test_hitl_and_signal.py`.
**Integration:** `tests/integration/test_outer_loop_*.py`, `test_resume_run.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| Progress line format / run-state suffixes | `loop/progress.py` |
| Emit sites / heartbeat / confirm | `loop/control.py` |
| Live event attach during step | `control.py` + `session/capabilities.py` (inject describer at CLI; never import `clm.agents.grok` from loop) |
| Verify-fail → handoff override | `control.py` (uses `CompactionPipeline` result) |
Keep loop free of `agent_kind` branches. CLI progress default lives in
`cli/run.py` (`make_progress_callback`).
+47
View File
@@ -0,0 +1,47 @@
# clm.memory
## Responsibility
Long-term memory for compact snapshots, handoff notes, and agent-shared knowledge:
`MemoryStore` protocol, file/mem0/hybrid backends, optional namespace wrapper,
path helpers, and backup/restore of the memory tree under `.clm/memory/`.
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `MemoryStore` | `base.py` | Protocol: `write` / `read` / `search` / `list_keys` |
| `build_memory_store` | `factory.py` | Config → store (`file` \| `mem0` \| `hybrid` + namespace) |
| `FileMemoryStore` | `file_store.py` | Default filesystem backend (topics + MEMORY.md index) |
| `Mem0Store`, `Mem0Client` | `mem0_store.py` | Optional Mem0; inject client for tests |
| `HybridMemoryStore` | `hybrid_store.py` | Write both; prefer file on read |
| `NamespacedMemoryStore` | `namespaced.py` | Key-prefix multi-agent isolation |
| `backup_memory`, `restore_memory` | `backup.py` | tar.gz under `.clm/backups/` |
| Path helpers | `paths.py` | Key sanitize, topic paths |
## Depends on
- `clm.models` (`MemoryEntry`, config fields for backend/namespace/keys).
- Optional external Mem0 SDK only behind factory / default client builder.
## Must not depend on
- `clm.loop`, `clm.session` adapters, `clm.cli` (CLI wraps `backup_*` only).
- Compaction/handoff *call* memory; memory must not call them.
## Tests
**Location:** `tests/unit/memory/` — `test_file_memory.py`, `test_memory_factory.py`,
`test_ab_memory.py`, `test_multi_agent.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| New backend | store class + `factory.py` + `ClmConfig.memory_backend` Literal |
| Key layout / sanitize | `paths.py`, `file_store.py` |
| Namespace behavior | `namespaced.py` |
| Backup format | `backup.py` + `cli/backup_cmd.py` |
Default root: `{clm_home}/memory/`. Prefer injectable clients for offline tests;
do not require live Mem0 in CI.
+47
View File
@@ -0,0 +1,47 @@
# clm.metrics
## Responsibility
Token accounting for the outer loop: estimate when providers omit usage, update
session cumulative tokens, compute optional **context window size**, append
append-only `metrics.jsonl`, and attach **cost** fields when prices are configured.
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `MetricsMonitor` | `monitor.py` | `update(state, step) → TokenSnapshot`; writes JSONL |
| `estimate_tokens` | `estimators.py` | Fallback: `chars // 4` |
| `estimate_context_tokens` | `monitor.py` | Window size over `state.messages` |
| Cost helpers | `cost.py` | `cost_for_config` / per-step session USD from config prices |
`TokenSnapshot` / step metrics DTOs live in `clm.models.metrics`. Export of
historical metrics is **`clm.export`** (not this package).
## Depends on
- `clm.models` (config, session step result, token DTOs)
- `clm.logging.audit` (`AuditLogger` for JSONL append)
## Must not depend on
- `clm.loop`, `clm.session` adapters, `clm.cli`, decision/compaction/handoff
- Live billing APIs (prices are local config only)
## Tests
**Location:** `tests/unit/metrics/` — `test_metrics_monitor.py`, `test_export_metrics.py`,
`test_calibration.py`. Cross-cutting cost/logging also in `tests/unit/production/`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| Estimation formula | `estimators.py` |
| Monitor fields / JSONL shape | `monitor.py` + `models/metrics.py` |
| Price table resolution | `cost.py` + `models/config.py` |
| Human export formats | `clm.export.metrics_export` + `cli/export_cmd.py` |
Decision engine consumes `TokenSnapshot`; threshold basis
(`total_tokens` vs `context_tokens`) is a config flag evaluated in `decision`, not
here. See also [`docs/metrics.md`](../metrics.md).
+50
View File
@@ -0,0 +1,50 @@
# clm.models
## Responsibility
Pydantic domain models and runtime configuration for CLM: session state, messages,
agent step results, decisions, token snapshots, handoff manifests, memory entries,
and `ClmConfig` (thresholds, profiles, agent settings, memory backend, confirm, cost).
## Public API
Re-exported from `clm.models` (`src/clm/models/__init__.py`):
| Symbol | Module | Role |
|--------|--------|------|
| `ClmConfig`, `AgentSettings`, `Thresholds`, `TaskType` / `TaskProfile` | `config.py` | Runtime config |
| `Decision`, `DecisionAction` | `decision.py` | CONTINUE / COMPACT / HANDOFF |
| `SessionState`, `Message`, `MessageRole`, `AgentStepResult`, `TaskStatus` | `session.py` | Working session shape |
| `TokenSnapshot`, `StepMetrics` | `metrics.py` | Token accounting DTOs |
| `HandoffManifest`, `ManifestStatus` | `manifest.py` | Schema-valid handoff |
| `MemoryEntry` | `memory.py` | Search hit DTO |
Key config knobs: `thresholds`, `min_steps_between_compacts`, `verify_compaction`,
`recall_score_threshold`, `memory_backend`, `memory_namespace`, `agent_kind`,
`agent` (`AgentSettings`), `require_confirm_on`, cost `provider` / `price_table`.
## Depends on
- Pydantic only (no CLM packages).
## Must not depend on
- Any runtime package: `loop`, `session`, `cli`, `memory` implementations, etc.
- Models are leaves; everything else depends *on* them.
## Tests
**Location:** `tests/unit/models/` — `test_config_thresholds.py`.
Manifest model tests: `tests/unit/handoff/test_models_manifest.py`.
Agent settings: `tests/unit/agents/test_agent_settings.py`.
## Agent notes (minimal file set)
| Change | Files |
|--------|--------|
| New decision trigger field / action | `models/decision.py` |
| Session / task status | `models/session.py` |
| Thresholds or agent CLI mapping | `models/config.py` |
| Manifest schema fields | `models/manifest.py` + `schemas/handoff_manifest.schema.json` + `docs/manifest-schema.md` |
Keep validators strict (e.g. `soft_compact < hard_handoff`). Prefer additive fields with defaults for schema evolution (`schema_version`).
+60
View File
@@ -0,0 +1,60 @@
# clm.runtime
## Responsibility
**Composition helpers** shared by CLI and library callers (experiments, tools):
CLM home layout / session path helpers, session state I/O, task-profile config
building, and Simulated script loading.
Exists so **`clm.experiments` does not import `clm.cli`**. `clm.cli.common`
re-exports many of these symbols for backward-compatible imports.
## Public API
| Symbol | Path | Role |
|--------|------|------|
| `resolve_clm_home`, `ensure_clm_layout` | `paths.py` | Absolute `.clm` home; standard tree + default `MEMORY.md` |
| `session_dir`, `state_path`, `decisions_path`, `metrics_path` | `paths.py` | Per-session paths under `{home}/sessions/<id>/` |
| `load_session_state`, `save_session_state` | `paths.py` | `state.json` I/O |
| `list_session_ids`, `latest_session_id` | `paths.py` | Session discovery |
| `read_jsonl_tail` | `paths.py` | Tail metrics/decisions for status/export |
| `DEFAULT_CLM_PATH`, `DEFAULT_MEMORY_MD` | `paths.py` | Defaults |
| `parse_profile`, `build_config` | `config_build.py` | Profile string → `TaskType`; build `ClmConfig` + agent settings |
| `load_script` | `script_loader.py` | JSON fixture → `list[AgentStepResult]` (Simulated) |
```python
from clm.runtime import (
ensure_clm_layout,
resolve_clm_home,
build_config,
load_script,
load_session_state,
save_session_state,
)
```
## Depends on
- `clm.models` only (`SessionState`, `ClmConfig`, `AgentSettings`, `TaskType`, …)
## Must not depend on
- `clm.cli`, `clm.loop`, `clm.agents`, `clm.session` (adapters or working session)
- Decision / compaction / handoff / metrics engines
## Tests
**Location:** `tests/unit/runtime/` — `test_runtime_paths.py` (layout helpers,
config build, script load). Import-boundary package graph:
`tests/unit/architecture/test_import_boundaries.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| New path under `.clm/` | `paths.py` (`ensure_clm_layout` + helpers) |
| New CLI flag that builds config | `config_build.py` + CLI command module |
| Simulated script format | `script_loader.py` + fixture schema |
**Do not** put Typer/CLI-only code here. Keep this package library-clean so
experiments and other runners can share it without importing the CLI.
+54
View File
@@ -0,0 +1,54 @@
# clm.session
## Responsibility
Working agent session and **vendor-free** coding-agent surface: `Agent` protocol,
optional capabilities, `WorkingSession` / `TrackingWorkingSession`, and a pure
agent factory registry (`register_agent` / `build_agent`).
**Vendor adapters** live under [`clm.agents`](agents.md) (Simulated, Grok Build,
OpenAI-compatible). Built-ins register via `clm.agents.register_builtin_agents`
(import `clm.agents` at composition root / CLI).
## Public API
From package / primary modules:
| Symbol | Path | Role |
|--------|------|------|
| `Agent` | `agent.py` | Protocol: `step` / `inject_context` / `reset_with_seed` |
| `ExternalSessionCapable`, `LiveEventCapable`, `LiveEventHandler` | `capabilities.py` | Optional dual-session + live events |
| `WorkingSession`, `TrackingWorkingSession` | `working.py`, `tracking.py` | State + agent step; persistence/external id sync |
| `build_agent`, `register_agent`, `registered_agents`, `unregister_agent` | `factory.py` | Kind → instance registry (no vendor imports) |
## Depends on
- `clm.models` (config, session DTOs).
## Must not depend on
- `clm.agents` (adapters depend on session protocols, not the reverse)
- `clm.loop`, `clm.decision`, `clm.compaction`, `clm.handoff`, `clm.cli`
**Boundary note:** `clm.loop` does not import adapter modules. The composition root
(CLI) injects `describe_stream_event` as `OuterControlLoop.describe_live_event`
when running grok-build so live `doing=` labels stay vendor-agnostic in the loop.
## Tests
**Location:** `tests/unit/session/` — `test_tracking_session.py`,
`test_live_event_capable.py`.
**Adapters / factory:** `tests/unit/agents/`. Multi-agent memory:
`tests/unit/memory/test_multi_agent.py`.
## Agent notes (minimal file set)
| Task | Minimal files |
|------|----------------|
| New adapter | `clm.agents.*` + `register_builtin_agents` / custom `register_agent` |
| Dual session | `capabilities.ExternalSessionCapable`, `tracking.py` |
| Live mid-step events | `LiveEventCapable` + adapter `set_live_event_handler` |
**Do not** teach `OuterControlLoop` about concrete adapter classes; use protocols.
See [`docs/coding-agents.md`](../coding-agents.md).
+39
View File
@@ -0,0 +1,39 @@
"""Vendor / coding-agent adapters for CLM.
Session core (:mod:`clm.session`) stays vendor-free. Adapters and default
factory builders live here. Importing this package registers built-ins
(``simulated``, ``grok-build``) via :func:`register_builtin_agents`.
"""
from clm.agents.grok import (
FakeGrokCliRunner,
GrokBuildAgent,
GrokCliError,
GrokCliRequest,
GrokCliResult,
GrokCliRunner,
SubprocessGrokCliRunner,
describe_stream_event,
parse_clm_task_status,
)
from clm.agents.openai_compatible import OpenAICompatibleAgent
from clm.agents.register import register_builtin_agents
from clm.agents.simulated import ScriptSource, SimulatedAgent
register_builtin_agents()
__all__ = [
"FakeGrokCliRunner",
"GrokBuildAgent",
"GrokCliError",
"GrokCliRequest",
"GrokCliResult",
"GrokCliRunner",
"OpenAICompatibleAgent",
"ScriptSource",
"SimulatedAgent",
"SubprocessGrokCliRunner",
"describe_stream_event",
"parse_clm_task_status",
"register_builtin_agents",
]
+34
View File
@@ -0,0 +1,34 @@
"""Grok Build coding-agent adapter and headless CLI helpers."""
from clm.agents.grok.agent import GrokBuildAgent, parse_clm_task_status
from clm.agents.grok.cli import (
FakeGrokCliRunner,
GrokCliError,
GrokCliRequest,
GrokCliResult,
GrokCliRunner,
SubprocessGrokCliRunner,
build_argv,
parse_grok_json_payload,
)
from clm.agents.grok.stream import (
aggregate_stream_events,
describe_stream_event,
parse_stream_line,
)
__all__ = [
"FakeGrokCliRunner",
"GrokBuildAgent",
"GrokCliError",
"GrokCliRequest",
"GrokCliResult",
"GrokCliRunner",
"SubprocessGrokCliRunner",
"aggregate_stream_events",
"build_argv",
"describe_stream_event",
"parse_clm_task_status",
"parse_grok_json_payload",
"parse_stream_line",
]
@@ -1,7 +1,7 @@
"""Grok Build coding-agent adapter (first live registry entry).
Maps the framework-agnostic :class:`~clm.session.agent.Agent` protocol onto
headless ``grok -p`` via an injectable :class:`~clm.session.grok_cli.GrokCliRunner`.
headless ``grok -p`` via an injectable :class:`~clm.agents.grok.cli.GrokCliRunner`.
When a live-event sink is registered (see :class:`LiveEventCapable`), the adapter
uses Grok ``streaming-json`` (ACP-derived NDJSON) so mid-step tools/plan/text
@@ -15,9 +15,9 @@ from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from clm.agents.grok.cli import GrokCliRequest, GrokCliRunner
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.capabilities import LiveEventHandler
from clm.session.grok_cli import GrokCliRequest, GrokCliRunner
# Completion / status marker embedded in agent reply (control plane).
_STATUS_MARKER = re.compile(
@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol, runtime_checkable
from clm.session.grok_stream import (
from clm.agents.grok.stream import (
aggregate_stream_events,
parse_stream_line,
)
@@ -11,12 +11,12 @@ from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from clm.session.grok_cli import GrokCliResult
from clm.agents.grok.cli import GrokCliResult
def parse_stream_line(line: str) -> dict[str, Any] | None:
"""Parse one NDJSON line; blank → ``None``; invalid JSON → :class:`GrokCliError`."""
from clm.session.grok_cli import GrokCliError
from clm.agents.grok.cli import GrokCliError
text = line.strip()
if not text:
@@ -31,6 +31,7 @@ def parse_stream_line(line: str) -> dict[str, Any] | None:
)
return payload
def _truncate(text: str, max_len: int) -> str:
text = text.replace("\n", " ").strip()
if len(text) <= max_len:
@@ -118,7 +119,7 @@ def aggregate_stream_events(events: Sequence[Mapping[str, Any]]) -> GrokCliResul
Requires a terminal ``end`` event, or raises :class:`GrokCliError` on ``error``
or missing terminal.
"""
from clm.session.grok_cli import GrokCliError, GrokCliResult, parse_grok_json_payload
from clm.agents.grok.cli import GrokCliError, GrokCliResult, parse_grok_json_payload
if not events:
raise GrokCliError("Empty streaming-json event stream")
File renamed without changes.
+85
View File
@@ -0,0 +1,85 @@
"""Register built-in coding-agent adapters with the session factory.
Keeps :mod:`clm.session.factory` free of vendor imports: builders and default
kinds live here. Call :func:`register_builtin_agents` once at composition root
(or via :mod:`clm.agents` package import). Idempotent (overwrites same keys).
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from clm.models.config import ClmConfig
from clm.models.session import AgentStepResult
from clm.session.agent import Agent
from clm.session.factory import register_agent
def _build_simulated(
config: ClmConfig,
*,
script_steps: Sequence[AgentStepResult] | None = None,
goal: str | None = None,
runner: Any | None = None,
**_: Any,
) -> Agent:
from clm.agents.simulated import SimulatedAgent
if script_steps is None:
raise ValueError(
"simulated agent requires script_steps or agent.script_path loaded by CLI"
)
return SimulatedAgent(
list(script_steps),
script_offset=config.agent.script_offset,
)
def _build_grok_build(
config: ClmConfig,
*,
script_steps: Sequence[AgentStepResult] | None = None,
goal: str | None = None,
runner: Any | None = None,
**_: Any,
) -> Agent:
from clm.agents.grok.agent import GrokBuildAgent
from clm.agents.grok.cli import SubprocessGrokCliRunner
if script_steps is not None:
raise ValueError("script_steps is only valid for agent_kind=simulated")
if not goal or not str(goal).strip():
raise ValueError("grok-build agent requires a non-empty goal")
settings = config.agent
opts = settings.options
grok_bin = str(opts.get("grok_bin", "grok"))
timeout_s = float(opts.get("timeout_s", 3600))
cli_runner = runner or SubprocessGrokCliRunner(
grok_bin=grok_bin,
timeout_s=timeout_s,
)
model = settings.model or "grok-build"
# stream default True; disable with --agent-option stream=false
stream_opt = opts.get("stream", True)
if isinstance(stream_opt, str):
stream = stream_opt.strip().lower() not in {"0", "false", "no", "off"}
else:
stream = bool(stream_opt)
return GrokBuildAgent(
runner=cli_runner,
cwd=settings.cwd,
goal=str(goal).strip(),
model=model,
max_turns_per_step=settings.max_turns_per_step,
yolo=settings.yolo,
rules=settings.rules,
stream=stream,
)
def register_builtin_agents() -> None:
"""Register ``simulated`` and ``grok-build`` (safe to call multiple times)."""
register_agent("simulated", _build_simulated)
register_agent("grok-build", _build_grok_build)
File renamed without changes.
+1
View File
@@ -7,6 +7,7 @@ from typing import Annotated
import typer
import clm.agents # noqa: F401 # register built-in agent kinds
from clm.cli.common import (
build_config,
ensure_clm_layout,
+43 -178
View File
@@ -1,88 +1,52 @@
"""Shared helpers for CLI commands (paths, state I/O, config)."""
"""Shared helpers for CLI commands (paths, state I/O, config).
Path/layout/state and config builders live in ``clm.runtime``; this module
re-exports them for backward-compatible ``from clm.cli.common import …``
and keeps CLI-only parsers (goal, confirm, agent-option).
"""
from __future__ import annotations
import json
from pathlib import Path
from clm.models.config import AgentSettings, ClmConfig, TaskType, Thresholds
from clm.models.session import SessionState
# Re-export composition helpers from clm.runtime (canonical home).
from clm.runtime.config_build import build_config, parse_profile
from clm.runtime.paths import (
DEFAULT_CLM_PATH,
DEFAULT_MEMORY_MD,
decisions_path,
ensure_clm_layout,
latest_session_id,
list_session_ids,
load_session_state,
metrics_path,
read_jsonl_tail,
resolve_clm_home,
save_session_state,
session_dir,
state_path,
)
DEFAULT_CLM_PATH = Path(".clm")
DEFAULT_MEMORY_MD = """# Memory Index
CLM file-backed memory. Topic entries live under `topics/` and are
indexed here when written by the outer loop (compact / handoff).
"""
def resolve_clm_home(path: Path | None) -> Path:
"""Return absolute CLM home directory (default ``.clm``)."""
home = Path(path) if path is not None else DEFAULT_CLM_PATH
return home.expanduser().resolve()
def ensure_clm_layout(home: Path) -> None:
"""Create standard ``.clm`` directory tree and default MEMORY.md if missing."""
(home / "memory" / "topics").mkdir(parents=True, exist_ok=True)
(home / "memory" / "logs").mkdir(parents=True, exist_ok=True)
(home / "sessions").mkdir(parents=True, exist_ok=True)
(home / "manifests").mkdir(parents=True, exist_ok=True)
(home / "backups").mkdir(parents=True, exist_ok=True)
(home / "exports").mkdir(parents=True, exist_ok=True)
(home / "calibrations").mkdir(parents=True, exist_ok=True)
memory_md = home / "memory" / "MEMORY.md"
if not memory_md.is_file():
memory_md.write_text(DEFAULT_MEMORY_MD, encoding="utf-8")
def session_dir(home: Path, session_id: str) -> Path:
return home / "sessions" / session_id
def state_path(home: Path, session_id: str) -> Path:
return session_dir(home, session_id) / "state.json"
def decisions_path(home: Path, session_id: str) -> Path:
return session_dir(home, session_id) / "decisions.jsonl"
def metrics_path(home: Path, session_id: str) -> Path:
return session_dir(home, session_id) / "metrics.jsonl"
def load_session_state(home: Path, session_id: str) -> SessionState:
"""Load ``sessions/<id>/state.json`` or raise ``FileNotFoundError`` with hint."""
path = state_path(home, session_id)
if not path.is_file():
raise FileNotFoundError(
f"No persisted state at {path}. "
"Run `clm run ...` first (it writes state.json at end of run)."
)
return SessionState.model_validate_json(path.read_text(encoding="utf-8"))
def save_session_state(home: Path, state: SessionState) -> Path:
"""Write SessionState dump to ``sessions/<id>/state.json``."""
path = state_path(home, state.session_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(state.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return path
def parse_profile(name: str) -> TaskType:
"""Map CLI profile string to TaskType."""
key = name.strip().lower().replace("-", "_")
try:
return TaskType(key)
except ValueError as exc:
valid = ", ".join(t.value for t in TaskType)
raise ValueError(f"Unknown profile {name!r}. Valid: {valid}") from exc
__all__ = [
"DEFAULT_CLM_PATH",
"DEFAULT_MEMORY_MD",
"build_config",
"decisions_path",
"ensure_clm_layout",
"latest_session_id",
"list_session_ids",
"load_session_state",
"metrics_path",
"parse_agent_options",
"parse_profile",
"parse_require_confirm",
"read_jsonl_tail",
"resolve_clm_home",
"resolve_goal",
"save_session_state",
"session_dir",
"state_path",
]
def resolve_goal(
@@ -171,102 +135,3 @@ def parse_agent_options(
except ValueError:
out[key] = val
return out
def build_config(
profile: str,
*,
clm_home: Path,
soft_compact: int | None = None,
hard_handoff: int | None = None,
min_steps_between_compacts: int | None = None,
memory_backend: str | None = None,
verify_compaction: bool | None = None,
recall_score_threshold: float | None = None,
require_confirm_on: list[str] | None = None,
agent_id: str | None = None,
memory_namespace: str | None = None,
provider: str | None = None,
price_per_1k_input: float | None = None,
price_per_1k_output: float | None = None,
agent_kind: str | None = None,
agent: AgentSettings | None = None,
) -> ClmConfig:
"""Build ClmConfig from profile with optional threshold / agent overrides."""
task = parse_profile(profile)
cfg = ClmConfig.for_task(task).model_copy(update={"clm_home": clm_home})
soft = soft_compact if soft_compact is not None else cfg.thresholds.soft_compact
hard = hard_handoff if hard_handoff is not None else cfg.thresholds.hard_handoff
updates: dict[str, object] = {
"thresholds": Thresholds(soft_compact=soft, hard_handoff=hard),
}
if min_steps_between_compacts is not None:
updates["min_steps_between_compacts"] = min_steps_between_compacts
if memory_backend is not None:
backend = memory_backend.strip().lower()
if backend not in ("file", "mem0", "hybrid"):
raise ValueError(
f"Unknown memory backend {memory_backend!r}. Valid: file, mem0, hybrid"
)
updates["memory_backend"] = backend
if verify_compaction is not None:
updates["verify_compaction"] = verify_compaction
if recall_score_threshold is not None:
updates["recall_score_threshold"] = recall_score_threshold
if require_confirm_on is not None:
updates["require_confirm_on"] = require_confirm_on
if agent_id is not None:
updates["agent_id"] = agent_id
if memory_namespace is not None:
updates["memory_namespace"] = memory_namespace
if provider is not None:
updates["provider"] = provider
if price_per_1k_input is not None:
updates["price_per_1k_input"] = price_per_1k_input
if price_per_1k_output is not None:
updates["price_per_1k_output"] = price_per_1k_output
if agent_kind is not None:
updates["agent_kind"] = agent_kind.strip().lower()
if agent is not None:
updates["agent"] = agent
return cfg.model_copy(update=updates)
def list_session_ids(home: Path) -> list[str]:
"""Return session directory names under ``home/sessions`` (sorted)."""
root = home / "sessions"
if not root.is_dir():
return []
return sorted(p.name for p in root.iterdir() if p.is_dir())
def latest_session_id(home: Path) -> str | None:
"""Pick the session with the newest decisions.jsonl (or state.json) mtime."""
best_id: str | None = None
best_mtime = -1.0
for sid in list_session_ids(home):
sdir = session_dir(home, sid)
candidates = [
sdir / "decisions.jsonl",
sdir / "metrics.jsonl",
sdir / "state.json",
]
mtimes = [p.stat().st_mtime for p in candidates if p.is_file()]
if not mtimes:
continue
m = max(mtimes)
if m > best_mtime:
best_mtime = m
best_id = sid
return best_id
def read_jsonl_tail(path: Path, n: int = 5) -> list[dict[str, object]]:
"""Read last ``n`` non-empty JSONL records (or all if fewer)."""
if not path.is_file():
return []
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
out: list[dict[str, object]] = []
for line in lines[-n:]:
out.append(json.loads(line))
return out
+9 -12
View File
@@ -2,13 +2,13 @@
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any
import typer
import clm.agents # noqa: F401 # register built-in agent kinds
from clm.cli.common import (
build_config,
decisions_path,
@@ -37,6 +37,7 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.runtime.script_loader import load_script
from clm.session.capabilities import ExternalSessionCapable
from clm.session.factory import build_agent, registered_agents
from clm.session.tracking import TrackingWorkingSession
@@ -81,17 +82,6 @@ def make_progress_callback() -> ProgressCallback:
return _progress
def load_script(script_path: Path) -> list[AgentStepResult]:
"""Load a JSON array of AgentStepResult objects from ``script_path``."""
path = Path(script_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"Script not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise TypeError(f"Script must be a JSON array, got {type(raw).__name__}")
return [AgentStepResult.model_validate(item) for item in raw]
def run_session(
*,
goal: str | None = None,
@@ -259,6 +249,12 @@ def run_session(
progress_cb: ProgressCallback | None = (
None if quiet else make_progress_callback()
)
describe_fn = None
if kind == "grok-build":
from clm.agents.grok.stream import describe_stream_event
describe_fn = describe_stream_event
loop = OuterControlLoop(
session=session,
monitor=MetricsMonitor(met_path, config=cfg),
@@ -273,6 +269,7 @@ def run_session(
config=cfg,
confirm_callback=confirm_cb,
progress_callback=progress_cb,
describe_live_event=describe_fn,
)
result = loop.run(max_steps=max_steps)
+10 -10
View File
@@ -9,15 +9,7 @@ from typing import Any
from pydantic import BaseModel, Field
from clm.cli.common import (
build_config,
decisions_path,
ensure_clm_layout,
metrics_path,
save_session_state,
session_dir,
)
from clm.cli.run import load_script
from clm.agents.simulated import SimulatedAgent
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
@@ -29,7 +21,15 @@ from clm.metrics.cost import cost_for_config
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import ClmConfig
from clm.models.session import Message, MessageRole, SessionState, TaskStatus
from clm.session.simulated import SimulatedAgent
from clm.runtime import (
build_config,
decisions_path,
ensure_clm_layout,
load_script,
metrics_path,
save_session_state,
session_dir,
)
from clm.session.working import WorkingSession
_VALID_BACKENDS: frozenset[str] = frozenset({"file", "mem0", "hybrid"})
+6 -11
View File
@@ -32,16 +32,6 @@ from clm.models.session import SessionState, TaskStatus
from clm.session.capabilities import ExternalSessionCapable, LiveEventCapable
from clm.session.working import WorkingSession
# Optional describe helper for ACP/stream events (lazy to keep loop vendor-light).
def _describe_live_event(event: Mapping[str, Any]) -> str | None:
"""Best-effort human activity string from a live agent event."""
try:
from clm.session.grok_stream import describe_stream_event
except ImportError: # pragma: no cover
return None
return describe_stream_event(event)
_TERMINAL = frozenset({TaskStatus.COMPLETED, TaskStatus.FAILED})
_DEFAULT_HEARTBEAT_S = 20.0
@@ -108,6 +98,7 @@ class OuterControlLoop:
confirm_callback: ConfirmCallback | None = None,
progress_callback: ProgressCallback | None = None,
progress_heartbeat_s: float = _DEFAULT_HEARTBEAT_S,
describe_live_event: Callable[[Mapping[str, Any]], str | None] | None = None,
) -> None:
self.session = session
self.monitor = monitor
@@ -121,6 +112,8 @@ class OuterControlLoop:
self.confirm_callback = confirm_callback
self.progress_callback = progress_callback
self.progress_heartbeat_s = progress_heartbeat_s
# Vendor-free: inject stream/event describer at composition root (e.g. CLI).
self.describe_live_event = describe_live_event
def _emit(self, kind: ProgressKind, **fields: Any) -> None:
if self.progress_callback is None:
@@ -255,7 +248,9 @@ class OuterControlLoop:
def _on_event(event: Mapping[str, Any]) -> None:
etype = str(event.get("type") or "")
desc = _describe_live_event(event)
desc = None
if self.describe_live_event is not None:
desc = self.describe_live_event(event)
if desc:
live_state["doing"] = desc
live_state["activity"] = "agent_streaming"
+43
View File
@@ -0,0 +1,43 @@
"""Composition helpers shared by CLI and library callers (paths, config, scripts).
Vendor-free layer above ``clm.models`` for layout/state I/O and config building.
``clm.cli`` and ``clm.experiments`` both depend on this package; experiments must
not import ``clm.cli``.
"""
from clm.runtime.config_build import build_config, parse_profile
from clm.runtime.paths import (
DEFAULT_CLM_PATH,
DEFAULT_MEMORY_MD,
decisions_path,
ensure_clm_layout,
latest_session_id,
list_session_ids,
load_session_state,
metrics_path,
read_jsonl_tail,
resolve_clm_home,
save_session_state,
session_dir,
state_path,
)
from clm.runtime.script_loader import load_script
__all__ = [
"DEFAULT_CLM_PATH",
"DEFAULT_MEMORY_MD",
"build_config",
"decisions_path",
"ensure_clm_layout",
"latest_session_id",
"list_session_ids",
"load_script",
"load_session_state",
"metrics_path",
"parse_profile",
"read_jsonl_tail",
"resolve_clm_home",
"save_session_state",
"session_dir",
"state_path",
]
+80
View File
@@ -0,0 +1,80 @@
"""Build ``ClmConfig`` from task profile and optional overrides.
Shared by CLI and library callers (e.g. experiments). Depends only on
``clm.models`` (no CLI / typer).
"""
from __future__ import annotations
from pathlib import Path
from clm.models.config import AgentSettings, ClmConfig, TaskType, Thresholds
def parse_profile(name: str) -> TaskType:
"""Map profile string to TaskType."""
key = name.strip().lower().replace("-", "_")
try:
return TaskType(key)
except ValueError as exc:
valid = ", ".join(t.value for t in TaskType)
raise ValueError(f"Unknown profile {name!r}. Valid: {valid}") from exc
def build_config(
profile: str,
*,
clm_home: Path,
soft_compact: int | None = None,
hard_handoff: int | None = None,
min_steps_between_compacts: int | None = None,
memory_backend: str | None = None,
verify_compaction: bool | None = None,
recall_score_threshold: float | None = None,
require_confirm_on: list[str] | None = None,
agent_id: str | None = None,
memory_namespace: str | None = None,
provider: str | None = None,
price_per_1k_input: float | None = None,
price_per_1k_output: float | None = None,
agent_kind: str | None = None,
agent: AgentSettings | None = None,
) -> ClmConfig:
"""Build ClmConfig from profile with optional threshold / agent overrides."""
task = parse_profile(profile)
cfg = ClmConfig.for_task(task).model_copy(update={"clm_home": clm_home})
soft = soft_compact if soft_compact is not None else cfg.thresholds.soft_compact
hard = hard_handoff if hard_handoff is not None else cfg.thresholds.hard_handoff
updates: dict[str, object] = {
"thresholds": Thresholds(soft_compact=soft, hard_handoff=hard),
}
if min_steps_between_compacts is not None:
updates["min_steps_between_compacts"] = min_steps_between_compacts
if memory_backend is not None:
backend = memory_backend.strip().lower()
if backend not in ("file", "mem0", "hybrid"):
raise ValueError(
f"Unknown memory backend {memory_backend!r}. Valid: file, mem0, hybrid"
)
updates["memory_backend"] = backend
if verify_compaction is not None:
updates["verify_compaction"] = verify_compaction
if recall_score_threshold is not None:
updates["recall_score_threshold"] = recall_score_threshold
if require_confirm_on is not None:
updates["require_confirm_on"] = require_confirm_on
if agent_id is not None:
updates["agent_id"] = agent_id
if memory_namespace is not None:
updates["memory_namespace"] = memory_namespace
if provider is not None:
updates["provider"] = provider
if price_per_1k_input is not None:
updates["price_per_1k_input"] = price_per_1k_input
if price_per_1k_output is not None:
updates["price_per_1k_output"] = price_per_1k_output
if agent_kind is not None:
updates["agent_kind"] = agent_kind.strip().lower()
if agent is not None:
updates["agent"] = agent
return cfg.model_copy(update=updates)
+118
View File
@@ -0,0 +1,118 @@
"""CLM home layout, session paths, and session state I/O.
Pure path/layout helpers used by CLI and library callers (e.g. experiments).
Depends only on stdlib + ``clm.models.session.SessionState``.
"""
from __future__ import annotations
import json
from pathlib import Path
from clm.models.session import SessionState
DEFAULT_CLM_PATH = Path(".clm")
DEFAULT_MEMORY_MD = """# Memory Index
CLM file-backed memory. Topic entries live under `topics/` and are
indexed here when written by the outer loop (compact / handoff).
"""
def resolve_clm_home(path: Path | None) -> Path:
"""Return absolute CLM home directory (default ``.clm``)."""
home = Path(path) if path is not None else DEFAULT_CLM_PATH
return home.expanduser().resolve()
def ensure_clm_layout(home: Path) -> None:
"""Create standard ``.clm`` directory tree and default MEMORY.md if missing."""
(home / "memory" / "topics").mkdir(parents=True, exist_ok=True)
(home / "memory" / "logs").mkdir(parents=True, exist_ok=True)
(home / "sessions").mkdir(parents=True, exist_ok=True)
(home / "manifests").mkdir(parents=True, exist_ok=True)
(home / "backups").mkdir(parents=True, exist_ok=True)
(home / "exports").mkdir(parents=True, exist_ok=True)
(home / "calibrations").mkdir(parents=True, exist_ok=True)
memory_md = home / "memory" / "MEMORY.md"
if not memory_md.is_file():
memory_md.write_text(DEFAULT_MEMORY_MD, encoding="utf-8")
def session_dir(home: Path, session_id: str) -> Path:
return home / "sessions" / session_id
def state_path(home: Path, session_id: str) -> Path:
return session_dir(home, session_id) / "state.json"
def decisions_path(home: Path, session_id: str) -> Path:
return session_dir(home, session_id) / "decisions.jsonl"
def metrics_path(home: Path, session_id: str) -> Path:
return session_dir(home, session_id) / "metrics.jsonl"
def load_session_state(home: Path, session_id: str) -> SessionState:
"""Load ``sessions/<id>/state.json`` or raise ``FileNotFoundError`` with hint."""
path = state_path(home, session_id)
if not path.is_file():
raise FileNotFoundError(
f"No persisted state at {path}. "
"Run `clm run ...` first (it writes state.json at end of run)."
)
return SessionState.model_validate_json(path.read_text(encoding="utf-8"))
def save_session_state(home: Path, state: SessionState) -> Path:
"""Write SessionState dump to ``sessions/<id>/state.json``."""
path = state_path(home, state.session_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(state.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return path
def list_session_ids(home: Path) -> list[str]:
"""Return session directory names under ``home/sessions`` (sorted)."""
root = home / "sessions"
if not root.is_dir():
return []
return sorted(p.name for p in root.iterdir() if p.is_dir())
def latest_session_id(home: Path) -> str | None:
"""Pick the session with the newest decisions.jsonl (or state.json) mtime."""
best_id: str | None = None
best_mtime = -1.0
for sid in list_session_ids(home):
sdir = session_dir(home, sid)
candidates = [
sdir / "decisions.jsonl",
sdir / "metrics.jsonl",
sdir / "state.json",
]
mtimes = [p.stat().st_mtime for p in candidates if p.is_file()]
if not mtimes:
continue
m = max(mtimes)
if m > best_mtime:
best_mtime = m
best_id = sid
return best_id
def read_jsonl_tail(path: Path, n: int = 5) -> list[dict[str, object]]:
"""Read last ``n`` non-empty JSONL records (or all if fewer)."""
if not path.is_file():
return []
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
out: list[dict[str, object]] = []
for line in lines[-n:]:
out.append(json.loads(line))
return out
+19
View File
@@ -0,0 +1,19 @@
"""Load Simulated agent scripts (JSON → list[AgentStepResult])."""
from __future__ import annotations
import json
from pathlib import Path
from clm.models.session import AgentStepResult
def load_script(script_path: Path) -> list[AgentStepResult]:
"""Load a JSON array of AgentStepResult objects from ``script_path``."""
path = Path(script_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"Script not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise TypeError(f"Script must be a JSON array, got {type(raw).__name__}")
return [AgentStepResult.model_validate(item) for item in raw]
+14 -20
View File
@@ -1,35 +1,29 @@
"""Working session, Agent protocol, factory, and adapters."""
"""Working session, Agent protocol, capabilities, and pure agent factory."""
from clm.session.agent import Agent
from clm.session.capabilities import ExternalSessionCapable
from clm.session.factory import build_agent, register_agent, registered_agents
from clm.session.grok_build import GrokBuildAgent
from clm.session.grok_cli import (
FakeGrokCliRunner,
GrokCliError,
GrokCliRequest,
GrokCliResult,
GrokCliRunner,
SubprocessGrokCliRunner,
from clm.session.capabilities import (
ExternalSessionCapable,
LiveEventCapable,
LiveEventHandler,
)
from clm.session.factory import (
build_agent,
register_agent,
registered_agents,
unregister_agent,
)
from clm.session.simulated import SimulatedAgent
from clm.session.tracking import TrackingWorkingSession
from clm.session.working import WorkingSession
__all__ = [
"Agent",
"ExternalSessionCapable",
"FakeGrokCliRunner",
"GrokBuildAgent",
"GrokCliError",
"GrokCliRequest",
"GrokCliResult",
"GrokCliRunner",
"SimulatedAgent",
"SubprocessGrokCliRunner",
"LiveEventCapable",
"LiveEventHandler",
"TrackingWorkingSession",
"WorkingSession",
"build_agent",
"register_agent",
"registered_agents",
"unregister_agent",
]
+4 -68
View File
@@ -2,6 +2,10 @@
Mirrors :mod:`clm.memory.factory`: config selects a kind, registry maps kind
to a builder. Outer loop never branches on vendor names.
Built-in adapters (``simulated``, ``grok-build``) are registered by
:func:`clm.agents.register_builtin_agents` — import :mod:`clm.agents` (or call
that function) before :func:`build_agent`.
"""
from __future__ import annotations
@@ -69,71 +73,3 @@ def build_agent(
runner=runner,
**overrides,
)
def _build_simulated(
config: ClmConfig,
*,
script_steps: Sequence[AgentStepResult] | None = None,
goal: str | None = None,
runner: Any | None = None,
**_: Any,
) -> Agent:
from clm.session.simulated import SimulatedAgent
if script_steps is None:
raise ValueError(
"simulated agent requires script_steps or agent.script_path loaded by CLI"
)
return SimulatedAgent(
list(script_steps),
script_offset=config.agent.script_offset,
)
def _build_grok_build(
config: ClmConfig,
*,
script_steps: Sequence[AgentStepResult] | None = None,
goal: str | None = None,
runner: Any | None = None,
**_: Any,
) -> Agent:
from clm.session.grok_build import GrokBuildAgent
from clm.session.grok_cli import SubprocessGrokCliRunner
if script_steps is not None:
raise ValueError("script_steps is only valid for agent_kind=simulated")
if not goal or not str(goal).strip():
raise ValueError("grok-build agent requires a non-empty goal")
settings = config.agent
opts = settings.options
grok_bin = str(opts.get("grok_bin", "grok"))
timeout_s = float(opts.get("timeout_s", 3600))
cli_runner = runner or SubprocessGrokCliRunner(
grok_bin=grok_bin,
timeout_s=timeout_s,
)
model = settings.model or "grok-build"
# stream default True; disable with --agent-option stream=false
stream_opt = opts.get("stream", True)
if isinstance(stream_opt, str):
stream = stream_opt.strip().lower() not in {"0", "false", "no", "off"}
else:
stream = bool(stream_opt)
return GrokBuildAgent(
runner=cli_runner,
cwd=settings.cwd,
goal=str(goal).strip(),
model=model,
max_turns_per_step=settings.max_turns_per_step,
yolo=settings.yolo,
rules=settings.rules,
stream=stream,
)
# Default offline agent + first live coding adapter.
register_agent("simulated", _build_simulated)
register_agent("grok-build", _build_grok_build)
View File
Whitespace-only changes.
+25
View File
@@ -1,7 +1,15 @@
"""Shared pytest fixtures and suite bootstrap."""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
import clm.agents # noqa: F401 # register built-in agent kinds (simulated, grok-build)
from tests.support.fakes import DictMemoryStore, FakeAgent, agent_registry_snapshot
@pytest.fixture
def tmp_clm_home(tmp_path: Path) -> Path:
@@ -11,3 +19,20 @@ def tmp_clm_home(tmp_path: Path) -> Path:
(home / "sessions").mkdir(parents=True)
(home / "manifests").mkdir(parents=True)
return home
@pytest.fixture
def fake_agent() -> FakeAgent:
return FakeAgent()
@pytest.fixture
def dict_memory() -> DictMemoryStore:
return DictMemoryStore()
@pytest.fixture
def agent_registry() -> Iterator[None]:
"""Isolate agent factory registry mutations for the duration of a test."""
with agent_registry_snapshot():
yield
+1 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from pathlib import Path
from clm.agents.simulated import SimulatedAgent
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
@@ -21,7 +22,6 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from pathlib import Path
from clm.agents.simulated import SimulatedAgent
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
@@ -21,7 +22,6 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
+1 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from pathlib import Path
from clm.agents.simulated import SimulatedAgent
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
@@ -21,7 +22,6 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
@@ -5,6 +5,8 @@ from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from clm.agents.grok.agent import GrokBuildAgent
from clm.agents.grok.cli import FakeGrokCliRunner, GrokCliResult
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
@@ -22,8 +24,6 @@ from clm.models.session import (
TaskStatus,
)
from clm.session.factory import build_agent, register_agent, unregister_agent
from clm.session.grok_build import GrokBuildAgent
from clm.session.grok_cli import FakeGrokCliRunner, GrokCliResult
from clm.session.tracking import TrackingWorkingSession
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from pathlib import Path
from clm.agents.simulated import SimulatedAgent
from clm.compaction.pipeline import CompactionPipeline
from clm.compaction.verify import VerificationSuite, VerifyResult
from clm.decision.engine import DecisionEngine
@@ -22,7 +23,6 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
+1 -1
View File
@@ -7,9 +7,9 @@ from pathlib import Path
from typer.testing import CliRunner
from clm.agents.simulated import SimulatedAgent
from clm.cli.app import app
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.simulated import SimulatedAgent
runner = CliRunner()
+1
View File
@@ -0,0 +1 @@
"""Shared test support: fakes and fixtures for CLM unit/integration tests."""
+134
View File
@@ -0,0 +1,134 @@
"""Shared test fakes: FakeAgent, DictMemoryStore, agent registry snapshot."""
from __future__ import annotations
from collections.abc import Callable, Iterator, Sequence
from contextlib import contextmanager
from datetime import UTC, datetime
from typing import Any
from clm.models.memory import MemoryEntry
from clm.models.session import AgentStepResult, Message, TaskStatus
type ScriptSource = list[AgentStepResult] | Callable[[Sequence[Message]], AgentStepResult]
def _utc_now_iso() -> str:
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
class FakeAgent:
"""Minimal Agent protocol impl for tests (mirrors SimulatedAgent surface).
Accepts a list script, a callable script, or ``None`` (always returns a
default COMPLETED step). Exposes ``system_addons``, ``reset_called``, and
``seed_messages`` for outer-loop assertions.
"""
def __init__(
self,
script: list[AgentStepResult] | Callable[..., Any] | None = None,
) -> None:
self._script: list[AgentStepResult] | None
self._callable: Callable[[Sequence[Message]], AgentStepResult] | None
if script is None:
self._script = None
self._callable = None
elif isinstance(script, list):
self._script = list(script)
self._callable = None
else:
self._script = None
self._callable = script # type: ignore[assignment]
self._index = 0
self.system_addons: list[str] = []
self.reset_called: bool = False
self.seed_messages: list[Message] = []
def step(self, messages: Sequence[Message]) -> AgentStepResult:
"""Return the next scripted result, invoke the callable, or default COMPLETED."""
if self._callable is not None:
return self._callable(messages)
if self._script is not None:
if self._index >= len(self._script):
return AgentStepResult(
messages_added=[],
tool_calls=0,
input_tokens=0,
output_tokens=0,
task_status=TaskStatus.COMPLETED,
notes="script exhausted",
)
result = self._script[self._index]
self._index += 1
return result
return AgentStepResult(
messages_added=[],
tool_calls=0,
input_tokens=0,
output_tokens=0,
task_status=TaskStatus.COMPLETED,
notes="fake default",
)
def inject_context(self, system_addon: str) -> None:
"""Record a system addon for later inspection."""
self.system_addons.append(system_addon)
def reset_with_seed(self, seed_messages: Sequence[Message]) -> None:
"""Mark reset and store seed messages (does not rewind script index)."""
self.reset_called = True
self.seed_messages = list(seed_messages)
class DictMemoryStore:
"""In-memory MemoryStore using a dict backend (write/read/list_keys/search)."""
def __init__(self) -> None:
# key -> (content, tags, updated_at)
self._data: dict[str, tuple[str, list[str], str]] = {}
def write(self, key: str, content: str, *, tags: list[str] | None = None) -> None:
tag_list = list(tags) if tags is not None else []
self._data[key] = (content, tag_list, _utc_now_iso())
def read(self, key: str) -> str | None:
entry = self._data.get(key)
if entry is None:
return None
return entry[0]
def search(self, query: str, *, limit: int = 10) -> list[MemoryEntry]:
needle = query.casefold()
hits: list[MemoryEntry] = []
for key, (content, tags, updated_at) in self._data.items():
if needle in content.casefold() or needle in key.casefold():
hits.append(
MemoryEntry(
key=key,
content=content,
tags=list(tags),
updated_at=updated_at,
)
)
if len(hits) >= limit:
break
return hits
def list_keys(self) -> list[str]:
return list(self._data.keys())
@contextmanager
def agent_registry_snapshot() -> Iterator[None]:
"""Snapshot and restore ``clm.session.factory._REGISTRY`` around a test block."""
from clm.session import factory as factory_mod
before = dict(factory_mod._REGISTRY)
try:
yield
finally:
factory_mod._REGISTRY.clear()
factory_mod._REGISTRY.update(before)
@@ -6,6 +6,7 @@ from collections.abc import Sequence
import pytest
from clm.agents.simulated import SimulatedAgent
from clm.models.config import ClmConfig
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.capabilities import ExternalSessionCapable
@@ -15,7 +16,6 @@ from clm.session.factory import (
registered_agents,
unregister_agent,
)
from clm.session.simulated import SimulatedAgent
@pytest.fixture(autouse=True)
File renamed without changes.
@@ -6,13 +6,13 @@ from pathlib import Path
import pytest
from clm.agents.grok.agent import GrokBuildAgent
from clm.agents.grok.cli import FakeGrokCliRunner, GrokCliResult
from clm.models.config import AgentSettings, ClmConfig
from clm.models.session import Message, MessageRole, TaskStatus
from clm.session.agent import Agent
from clm.session.capabilities import ExternalSessionCapable
from clm.session.factory import build_agent, registered_agents
from clm.session.grok_build import GrokBuildAgent
from clm.session.grok_cli import FakeGrokCliRunner, GrokCliResult
def _result(
@@ -2,8 +2,8 @@
from __future__ import annotations
from clm.agents.grok.agent import parse_clm_task_status
from clm.models.session import TaskStatus
from clm.session.grok_build import parse_clm_task_status
def test_parse_completed_marker() -> None:
@@ -7,7 +7,7 @@ from pathlib import Path
import pytest
from clm.session.grok_cli import (
from clm.agents.grok.cli import (
FakeGrokCliRunner,
GrokCliError,
GrokCliRequest,
@@ -4,8 +4,8 @@ from __future__ import annotations
import pytest
from clm.session.grok_cli import GrokCliError
from clm.session.grok_stream import (
from clm.agents.grok.cli import GrokCliError
from clm.agents.grok.stream import (
aggregate_stream_events,
describe_stream_event,
parse_stream_line,
@@ -6,8 +6,8 @@ import json
from pathlib import Path
from typing import Any
from clm.session.grok_build import GrokBuildAgent
from clm.session.grok_cli import (
from clm.agents.grok.agent import GrokBuildAgent
from clm.agents.grok.cli import (
FakeGrokCliRunner,
GrokCliRequest,
SubprocessGrokCliRunner,
@@ -112,7 +112,7 @@ def test_fake_stream_script_and_agent() -> None:
def test_fake_non_stream_still_works() -> None:
from clm.session.grok_cli import GrokCliResult
from clm.agents.grok.cli import GrokCliResult
fake = FakeGrokCliRunner(
[
@@ -5,14 +5,14 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.agent import Agent
from clm.session.openai_compatible import (
from clm.agents.openai_compatible import (
OpenAICompatibleAgent,
messages_to_openai,
parse_chat_completion,
)
from clm.session.simulated import SimulatedAgent
from clm.agents.simulated import SimulatedAgent
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.agent import Agent
def test_messages_to_openai_roles() -> None:
@@ -4,8 +4,8 @@ from pathlib import Path
def test_simulated_agent_plays_script() -> None:
from clm.agents.simulated import SimulatedAgent
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.simulated import SimulatedAgent
script = [
AgentStepResult(
@@ -31,7 +31,7 @@ def test_simulated_agent_plays_script() -> None:
def test_inject_and_reset() -> None:
from clm.session.simulated import SimulatedAgent
from clm.agents.simulated import SimulatedAgent
agent = SimulatedAgent([])
agent.inject_context("HINT")
@@ -42,6 +42,7 @@ def test_inject_and_reset() -> None:
def test_working_session_merges_messages_and_status() -> None:
from clm.agents.simulated import SimulatedAgent
from clm.models.session import (
AgentStepResult,
Message,
@@ -49,7 +50,6 @@ def test_working_session_merges_messages_and_status() -> None:
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
script = [
@@ -93,8 +93,8 @@ def test_working_session_merges_messages_and_status() -> None:
def test_simulated_agent_callable_script() -> None:
from clm.agents.simulated import SimulatedAgent
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.simulated import SimulatedAgent
def script(messages: list[Message]) -> AgentStepResult:
n = len(messages)
@@ -112,8 +112,8 @@ def test_simulated_agent_callable_script() -> None:
def test_simulated_agent_script_exhausted() -> None:
from clm.agents.simulated import SimulatedAgent
from clm.models.session import TaskStatus
from clm.session.simulated import SimulatedAgent
agent = SimulatedAgent([])
r = agent.step([])
@@ -123,8 +123,8 @@ def test_simulated_agent_script_exhausted() -> None:
def test_agent_protocol_runtime_checkable() -> None:
from clm.agents.simulated import SimulatedAgent
from clm.session.agent import Agent
from clm.session.simulated import SimulatedAgent
agent = SimulatedAgent([])
assert isinstance(agent, Agent)
@@ -134,7 +134,7 @@ def test_sample_transcript_fixture_loads() -> None:
from clm.models.session import AgentStepResult, TaskStatus
fixture = (
Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
Path(__file__).resolve().parents[2] / "fixtures" / "sample_transcript.json"
)
raw = fixture.read_text(encoding="utf-8")
# Fixture is a JSON array of AgentStepResult-shaped objects.
@@ -152,7 +152,7 @@ def test_sample_transcript_fixture_loads() -> None:
}
agent_from_fixture = __import__(
"clm.session.simulated", fromlist=["SimulatedAgent"]
"clm.agents.simulated", fromlist=["SimulatedAgent"]
).SimulatedAgent(steps)
first = agent_from_fixture.step([])
assert first.messages_added[0].content
@@ -0,0 +1,417 @@
"""Import-boundary guard for the clm package architecture.
Scans ``src/clm/**/*.py`` (static AST) and asserts that package-level edges
match the target dependency matrix, with an explicit allowlist of known
violations that future modularization steps must shrink — never grow.
"""
from __future__ import annotations
import ast
from collections import defaultdict
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SRC_CLM = _REPO_ROOT / "src" / "clm"
# ---------------------------------------------------------------------------
# Target architecture: package → packages it may import (besides itself)
# ---------------------------------------------------------------------------
ALLOWED: dict[str, set[str]] = {
"models": set(),
"session": {"models"},
# Vendor / coding-agent adapters — protocols + DTOs only
"agents": {"models", "session"},
"decision": {"models"},
"metrics": {"models", "logging"},
"logging": set(), # currently leaf; models allowed if needed later
"memory": {"models"},
"compaction": {"models", "memory"},
"handoff": {"models", "memory"},
"loop": {
"models",
"session",
"decision",
"metrics",
"compaction",
"handoff",
"logging",
},
"calibration": {"models"},
"export": {"models"}, # leaf today; models allowed for future DTOs
"experiments": {
"models",
"session",
"agents",
"loop",
"decision",
"metrics",
"compaction",
"handoff",
"memory",
"logging",
"runtime",
},
# Composition helpers (paths, config, script load) — models only for step 4
"runtime": {"models"},
# Composition root — may depend on almost everything
"cli": {
"models",
"session",
"agents",
"decision",
"metrics",
"logging",
"memory",
"compaction",
"handoff",
"loop",
"calibration",
"export",
"experiments",
"runtime",
},
}
# Package edges present today that violate the target (temporary allowlist).
# Removing an entry is fine (test still passes). Adding a new real edge that
# is neither ALLOWED nor listed here fails the suite.
KNOWN_PACKAGE_VIOLATIONS: set[tuple[str, str]] = set()
# Module-level vendor leaks: loop must not import grok-specific agent modules.
# Fixed (step 6): describe_live_event is injected at composition root; no grok stream.
FORBIDDEN_LOOP_MODULE_PREFIXES: tuple[str, ...] = (
"clm.agents.grok",
"clm.agents.simulated",
"clm.agents.openai_compatible",
# Legacy paths (pre-modularization) — keep forbidden if any reappear
"clm.session.grok_stream",
"clm.session.grok_cli",
"clm.session.grok_build",
"clm.session.simulated",
"clm.session.openai_compatible",
)
KNOWN_MODULE_VIOLATIONS: set[tuple[str, str]] = set()
# ---------------------------------------------------------------------------
# Scanning helpers
# ---------------------------------------------------------------------------
def _path_to_module(path: Path) -> str:
"""Map ``src/clm/.../foo.py`` → ``clm....foo`` (``__init__`` → package)."""
rel = path.relative_to(_SRC_CLM)
parts = list(rel.with_suffix("").parts)
if parts[-1] == "__init__":
parts = parts[:-1]
if not parts:
return "clm"
return "clm." + ".".join(parts)
def _package_of(module: str) -> str | None:
"""First component under ``clm`` (e.g. ``clm.loop.control`` → ``loop``)."""
parts = module.split(".")
if len(parts) < 2 or parts[0] != "clm":
return None
return parts[1]
def _containing_package_parts(path: Path) -> list[str]:
"""Path segments under clm for the package that contains ``path``."""
rel = path.relative_to(_SRC_CLM)
parts = list(rel.parts)
if parts[-1] == "__init__.py":
return parts[:-1]
return parts[:-1]
def _imported_modules_from_node(node: ast.AST, *, path: Path) -> list[str]:
"""Extract absolute ``clm...`` module names referenced by an import node."""
results: list[str] = []
if isinstance(node, ast.Import):
for alias in node.names:
name = alias.name
if name == "clm" or name.startswith("clm."):
results.append(name)
return results
if not isinstance(node, ast.ImportFrom):
return results
if node.level and node.level > 0:
containing = _containing_package_parts(path)
up = node.level - 1
if up > len(containing):
return results
base = containing[: len(containing) - up] if up else list(containing)
if node.module:
full_parts = base + node.module.split(".")
else:
full_parts = base
if full_parts:
results.append("clm." + ".".join(full_parts))
else:
results.append("clm")
return results
if node.module and (node.module == "clm" or node.module.startswith("clm.")):
results.append(node.module)
return results
def scan_clm_imports() -> tuple[
set[tuple[str, str]],
set[tuple[str, str]],
dict[tuple[str, str], list[str]],
]:
"""Scan production sources for clm internal imports.
Returns:
package_edges: set of (src_pkg, dst_pkg) with src != dst
module_edges: set of (src_module, dst_module) for clm.* imports
edge_locations: package edge → list of ``src_mod → dst_mod`` details
"""
package_edges: set[tuple[str, str]] = set()
module_edges: set[tuple[str, str]] = set()
edge_locations: dict[tuple[str, str], list[str]] = defaultdict(list)
for path in sorted(_SRC_CLM.rglob("*.py")):
if "__pycache__" in path.parts:
continue
src_mod = _path_to_module(path)
src_pkg = _package_of(src_mod)
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except SyntaxError as exc: # pragma: no cover
raise AssertionError(f"Syntax error in {path}: {exc}") from exc
for node in ast.walk(tree):
if not isinstance(node, (ast.Import, ast.ImportFrom)):
continue
for imported in _imported_modules_from_node(node, path=path):
if imported != "clm" and not imported.startswith("clm."):
continue
module_edges.add((src_mod, imported))
dst_pkg = _package_of(imported)
if src_pkg is None or dst_pkg is None:
continue
if src_pkg == dst_pkg:
continue
edge = (src_pkg, dst_pkg)
package_edges.add(edge)
edge_locations[edge].append(f"{src_mod} → {imported}")
return package_edges, module_edges, dict(edge_locations)
def _is_package_edge_allowed(src: str, dst: str) -> bool:
if src == dst:
return True
allowed = ALLOWED.get(src)
if allowed is None:
return False
return dst in allowed
def _loop_module_violations(
module_edges: set[tuple[str, str]],
) -> set[tuple[str, str]]:
"""Return (src, imported) where a loop module imports a forbidden vendor mod."""
found: set[tuple[str, str]] = set()
for src, imported in module_edges:
if not (src == "clm.loop" or src.startswith("clm.loop.")):
continue
for forbidden in FORBIDDEN_LOOP_MODULE_PREFIXES:
if imported == forbidden or imported.startswith(forbidden + "."):
found.add((src, imported))
break
return found
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def import_scan() -> tuple[
set[tuple[str, str]],
set[tuple[str, str]],
dict[tuple[str, str], list[str]],
]:
assert _SRC_CLM.is_dir(), f"expected clm package at {_SRC_CLM}"
return scan_clm_imports()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_no_unknown_package_edges(import_scan) -> None:
"""Every cross-package edge is either ALLOWED or in KNOWN_PACKAGE_VIOLATIONS."""
package_edges, _, locations = import_scan
unknown: list[str] = []
for src, dst in sorted(package_edges):
if _is_package_edge_allowed(src, dst):
continue
if (src, dst) in KNOWN_PACKAGE_VIOLATIONS:
continue
detail = "; ".join(locations.get((src, dst), []))
unknown.append(f" {src} → {dst} ({detail})")
assert not unknown, (
"Unknown package import edges (not in ALLOWED and not in "
"KNOWN_PACKAGE_VIOLATIONS):\n" + "\n".join(unknown)
)
def test_known_package_violations_are_allowlist_only(import_scan) -> None:
"""Known package violations are optional: if fixed, the test still passes.
Ensures the allowlist does not *require* violations to remain. New edges
must go through ``test_no_unknown_package_edges``.
"""
package_edges, _, _ = import_scan
for src, dst in sorted(KNOWN_PACKAGE_VIOLATIONS):
assert not _is_package_edge_allowed(src, dst), (
f"KNOWN_PACKAGE_VIOLATIONS entry {(src, dst)} is already ALLOWED — "
"remove it from the known set"
)
# Document which known violations are still live (informational)
still_present = KNOWN_PACKAGE_VIOLATIONS & package_edges
fixed = KNOWN_PACKAGE_VIOLATIONS - package_edges
assert still_present or fixed or not KNOWN_PACKAGE_VIOLATIONS
def test_no_unknown_module_violations(import_scan) -> None:
"""Loop must not import vendor agent modules except known leaks."""
_, module_edges, _ = import_scan
found = _loop_module_violations(module_edges)
unknown = found - KNOWN_MODULE_VIOLATIONS
assert not unknown, (
"Unknown module-level vendor leaks into clm.loop "
"(not in KNOWN_MODULE_VIOLATIONS):\n"
+ "\n".join(f" {s} → {d}" for s, d in sorted(unknown))
)
def test_known_module_violations_are_allowlist_only(import_scan) -> None:
"""Known module violations need not remain; stale allowlist entries OK."""
_, module_edges, _ = import_scan
found = _loop_module_violations(module_edges)
for src, dst in KNOWN_MODULE_VIOLATIONS:
assert src.startswith("clm.loop"), (
f"known module violation src must be under loop: {src}"
)
assert any(
dst == f or dst.startswith(f + ".") for f in FORBIDDEN_LOOP_MODULE_PREFIXES
), f"known module violation dst not in forbidden list: {dst}"
_ = found & KNOWN_MODULE_VIOLATIONS
def test_no_circular_package_imports(import_scan) -> None:
"""Reject cycles among packages, ignoring temporary known-violation edges.
Temporary known-violation edges are ignored so debt does not fail the
cycle check; once fixed, both directions must not form a ring again.
"""
package_edges, _, locations = import_scan
# Drop known-violation edges so temporary debt does not fail the cycle check.
clean_edges = package_edges - KNOWN_PACKAGE_VIOLATIONS
adj: dict[str, set[str]] = defaultdict(set)
for src, dst in clean_edges:
adj[src].add(dst)
cycles: list[str] = []
seen_pairs: set[frozenset[str]] = set()
for src, dsts in adj.items():
for dst in dsts:
if src in adj.get(dst, set()):
pair = frozenset((src, dst))
if pair in seen_pairs:
continue
seen_pairs.add(pair)
a, b = sorted(pair)
detail = "; ".join(
locations.get((a, b), []) + locations.get((b, a), [])
)
cycles.append(f" {a} ⇄ {b} ({detail})")
def _find_cycle_from(start: str) -> list[str] | None:
path: list[str] = [start]
on_path = {start}
def dfs(node: str) -> list[str] | None:
for nxt in sorted(adj.get(node, ())):
if nxt == start and len(path) >= 2:
return path + [nxt]
if nxt in on_path:
continue
on_path.add(nxt)
path.append(nxt)
found = dfs(nxt)
path.pop()
on_path.discard(nxt)
if found is not None:
return found
return None
return dfs(start)
nodes = sorted(set(adj) | {d for ds in adj.values() for d in ds})
reported: set[frozenset[str]] = set()
for n in nodes:
cyc = _find_cycle_from(n)
if not cyc:
continue
body = cyc[:-1] # drop repeated start
if len(set(body)) <= 2:
continue # 2-cycles already reported
key = frozenset(body)
if key in reported:
continue
reported.add(key)
cycles.append(" " + " → ".join(cyc))
assert not cycles, "Circular package imports detected:\n" + "\n".join(cycles)
def test_allowed_matrix_covers_known_packages() -> None:
"""Every top-level package under src/clm has an ALLOWED entry."""
packages = sorted(
p.name
for p in _SRC_CLM.iterdir()
if p.is_dir() and p.name != "__pycache__" and (p / "__init__.py").exists()
)
missing = [p for p in packages if p not in ALLOWED]
assert not missing, f"ALLOWED matrix missing packages: {missing}"
def test_session_package_has_no_vendor_adapter_modules() -> None:
"""session/ must not ship vendor adapter modules after modularization."""
forbidden_names = {
"simulated.py",
"openai_compatible.py",
"grok_build.py",
"grok_cli.py",
"grok_stream.py",
}
present = {
p.name
for p in (_SRC_CLM / "session").iterdir()
if p.is_file() and p.name in forbidden_names
}
assert not present, f"vendor modules still under session/: {sorted(present)}"
@@ -8,14 +8,14 @@ from pathlib import Path
import pytest
from typer.testing import CliRunner
from clm.agents.grok.cli import FakeGrokCliRunner, GrokCliResult
from clm.cli.app import app
from clm.cli.common import parse_agent_options
from clm.cli.run import run_session
from clm.models.session import Message, MessageRole
from clm.session.grok_cli import FakeGrokCliRunner, GrokCliResult
runner = CliRunner()
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sample_transcript.json"
def test_parse_agent_options_types() -> None:
@@ -10,7 +10,7 @@ from clm.cli.app import app
from clm.memory.file_store import FileMemoryStore
runner = CliRunner()
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sample_transcript.json"
def test_cli_backup_memory(tmp_path: Path) -> None:
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
from clm.cli.app import app
runner = CliRunner()
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sample_transcript.json"
def test_cli_help() -> None:
File renamed without changes.
File renamed without changes.
@@ -3,7 +3,7 @@ from pathlib import Path
from clm.models.manifest import HandoffManifest, ManifestStatus
FIXTURE = Path(__file__).parents[1] / "fixtures" / "sample_manifest.json"
FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sample_manifest.json"
def test_manifest_roundtrip_from_fixture():
@@ -7,6 +7,7 @@ from pathlib import Path
from typer.testing import CliRunner
from clm.agents.simulated import SimulatedAgent
from clm.cli.app import app
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
@@ -24,11 +25,10 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
runner = CliRunner()
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sample_transcript.json"
def test_confirm_denied_skips_handoff(tmp_clm_home: Path) -> None:
+185
View File
@@ -0,0 +1,185 @@
"""Outer loop wires LiveEventCapable → progress doing=."""
from __future__ import annotations
from pathlib import Path
from clm.agents.grok.agent import GrokBuildAgent
from clm.agents.grok.cli import FakeGrokCliRunner
from clm.agents.grok.stream import describe_stream_event
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
from clm.logging.audit import AuditLogger
from clm.loop.control import OuterControlLoop
from clm.loop.progress import ProgressEvent, ProgressKind
from clm.memory.file_store import FileMemoryStore
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import AgentSettings, ClmConfig, Thresholds
from clm.models.session import MessageRole, SessionState, TaskStatus
from clm.session.tracking import TrackingWorkingSession
def test_loop_emits_live_status_from_stream_events(tmp_path: Path) -> None:
clm_home = tmp_path / ".clm"
clm_home.mkdir()
store = FileMemoryStore(clm_home / "memory")
events = [
{
"type": "tool_call",
"toolCallId": "c1",
"toolName": "read_file",
"title": "Read",
"status": "in_progress",
"rawInput": {"path": "foo.py"},
},
{"type": "text", "data": "all good\nCLM_TASK_STATUS: completed"},
{
"type": "end",
"stopReason": "end_turn",
"sessionId": "live-1",
"num_turns": 1,
"usage": {
"input_tokens": 20,
"output_tokens": 5,
"cache_read_input_tokens": 0,
"total_tokens": 25,
},
},
]
fake = FakeGrokCliRunner(stream_scripts=[events])
agent = GrokBuildAgent(
runner=fake,
cwd=tmp_path,
goal="finish",
stream=True,
)
state = SessionState(
session_id="s-live",
goal="finish",
status=TaskStatus.RUNNING,
)
session = TrackingWorkingSession(agent, state)
cfg = ClmConfig(
agent_kind="grok-build",
agent=AgentSettings(cwd=tmp_path, max_turns_per_step=4, yolo=True),
thresholds=Thresholds(soft_compact=500_000, hard_handoff=600_000),
)
progress: list[ProgressEvent] = []
manifests = clm_home / "manifests"
manifests.mkdir(parents=True, exist_ok=True)
loop = OuterControlLoop(
session=session,
monitor=MetricsMonitor(clm_home / "m.jsonl"),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
store=store, config=cfg, manifests_dir=manifests
),
audit=AuditLogger(clm_home / "d.jsonl"),
config=cfg,
progress_callback=progress.append,
progress_heartbeat_s=0, # disable timer; we care about event STATUS
describe_live_event=describe_stream_event,
)
result = loop.run(max_steps=3)
assert result.stopped_reason == "completed"
status_events = [e for e in progress if e.kind == ProgressKind.STATUS]
# At least one STATUS from live tool_call
live_doings = [e.fields.get("doing") for e in status_events if e.fields.get("doing")]
assert any(d and "read_file" in str(d) for d in live_doings)
assert any(
e.fields.get("activity") == "agent_streaming" for e in status_events
)
# assistant message preserved
assert any(m.role == MessageRole.ASSISTANT for m in session.state.messages)
def test_loop_custom_describe_live_event_without_grok_stream(tmp_path: Path) -> None:
"""Injected describe_fn drives doing=; loop stays free of grok_stream."""
from collections.abc import Mapping, Sequence
from typing import Any
from clm.models.session import AgentStepResult, Message
from clm.session.capabilities import LiveEventCapable
from clm.session.working import WorkingSession
class FakeLiveAgent:
def __init__(self) -> None:
self._handler = None
def set_live_event_handler(self, handler) -> None:
self._handler = handler
def step(self, messages: Sequence[Message]) -> AgentStepResult:
if self._handler is not None:
self._handler(
{
"type": "tool_call",
"toolName": "custom_tool",
"status": "in_progress",
}
)
return AgentStepResult(
messages_added=[
Message(role=MessageRole.ASSISTANT, content="done")
],
input_tokens=5,
output_tokens=2,
task_status=TaskStatus.COMPLETED,
)
def inject_context(self, system_addon: str) -> None:
return None
def reset_with_seed(self, seed_messages: Sequence[Message]) -> None:
return None
def describe_fn(event: Mapping[str, Any]) -> str | None:
if event.get("type") == "tool_call":
return f"custom:{event.get('toolName')}"
return None
clm_home = tmp_path / ".clm"
clm_home.mkdir()
store = FileMemoryStore(clm_home / "memory")
agent = FakeLiveAgent()
assert isinstance(agent, LiveEventCapable)
state = SessionState(
session_id="s-custom-desc",
goal="finish",
status=TaskStatus.RUNNING,
)
session = WorkingSession(agent, state) # type: ignore[arg-type]
cfg = ClmConfig(
agent_kind="simulated",
thresholds=Thresholds(soft_compact=500_000, hard_handoff=600_000),
)
progress: list[ProgressEvent] = []
manifests = clm_home / "manifests"
manifests.mkdir(parents=True, exist_ok=True)
loop = OuterControlLoop(
session=session,
monitor=MetricsMonitor(clm_home / "m.jsonl"),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
store=store, config=cfg, manifests_dir=manifests
),
audit=AuditLogger(clm_home / "d.jsonl"),
config=cfg,
progress_callback=progress.append,
progress_heartbeat_s=0,
describe_live_event=describe_fn,
)
result = loop.run(max_steps=2)
assert result.stopped_reason == "completed"
status_events = [e for e in progress if e.kind == ProgressKind.STATUS]
live_doings = [e.fields.get("doing") for e in status_events if e.fields.get("doing")]
assert any(d == "custom:custom_tool" for d in live_doings)
# control.py must not reference grok_stream (boundary already enforced separately)
control_src = Path(__file__).resolve().parents[3] / "src" / "clm" / "loop" / "control.py"
assert "grok_stream" not in control_src.read_text(encoding="utf-8")
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from pathlib import Path
from clm.agents.simulated import SimulatedAgent
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
@@ -22,7 +23,6 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
@@ -6,6 +6,7 @@ import time
from collections.abc import Sequence
from pathlib import Path
from clm.agents.simulated import ScriptSource, SimulatedAgent
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
@@ -28,7 +29,6 @@ from clm.models.session import (
SessionState,
TaskStatus,
)
from clm.session.simulated import ScriptSource, SimulatedAgent
from clm.session.working import WorkingSession
@@ -6,7 +6,7 @@ from pathlib import Path
from clm.experiments.ab_memory import run_ab_memory_experiment, write_ab_report
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sample_transcript.json"
def test_ab_memory_file_only(tmp_path: Path) -> None:
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+149
View File
@@ -0,0 +1,149 @@
"""Unit tests for clm.runtime paths, script_loader, and session state I/O."""
from __future__ import annotations
from pathlib import Path
import pytest
from clm.models.session import SessionState, TaskStatus
from clm.runtime import (
ensure_clm_layout,
load_script,
load_session_state,
save_session_state,
session_dir,
)
from clm.runtime.config_build import build_config, parse_profile
from clm.runtime.paths import (
DEFAULT_MEMORY_MD,
list_session_ids,
read_jsonl_tail,
resolve_clm_home,
state_path,
)
FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "sample_transcript.json"
def test_ensure_clm_layout_creates_dirs(tmp_path: Path) -> None:
home = tmp_path / "clm_home"
ensure_clm_layout(home)
assert (home / "memory" / "topics").is_dir()
assert (home / "memory" / "logs").is_dir()
assert (home / "sessions").is_dir()
assert (home / "manifests").is_dir()
assert (home / "backups").is_dir()
assert (home / "exports").is_dir()
assert (home / "calibrations").is_dir()
memory_md = home / "memory" / "MEMORY.md"
assert memory_md.is_file()
assert memory_md.read_text(encoding="utf-8") == DEFAULT_MEMORY_MD
# Idempotent: does not overwrite existing MEMORY.md
memory_md.write_text("custom\n", encoding="utf-8")
ensure_clm_layout(home)
assert memory_md.read_text(encoding="utf-8") == "custom\n"
def test_load_script_sample_transcript() -> None:
steps = load_script(FIXTURE)
assert isinstance(steps, list)
assert len(steps) >= 1
assert steps[0].input_tokens >= 0
assert steps[0].messages_added
def test_load_script_missing_raises(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="Script not found"):
load_script(tmp_path / "nope.json")
def test_load_script_not_array(tmp_path: Path) -> None:
bad = tmp_path / "bad.json"
bad.write_text('{"not": "a list"}', encoding="utf-8")
with pytest.raises(TypeError, match="JSON array"):
load_script(bad)
def test_save_load_session_state_roundtrip(tmp_path: Path) -> None:
home = tmp_path / ".clm"
ensure_clm_layout(home)
state = SessionState(
session_id="sess-rt-1",
goal="roundtrip",
status=TaskStatus.RUNNING,
step_index=2,
)
path = save_session_state(home, state)
assert path == state_path(home, "sess-rt-1")
assert path.is_file()
assert (session_dir(home, "sess-rt-1")).is_dir()
loaded = load_session_state(home, "sess-rt-1")
assert loaded.session_id == "sess-rt-1"
assert loaded.goal == "roundtrip"
assert loaded.step_index == 2
assert loaded.status == TaskStatus.RUNNING
def test_load_session_state_missing(tmp_path: Path) -> None:
home = tmp_path / ".clm"
ensure_clm_layout(home)
with pytest.raises(FileNotFoundError, match="No persisted state"):
load_session_state(home, "missing")
def test_resolve_clm_home_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
home = resolve_clm_home(None)
assert home == (tmp_path / ".clm").resolve()
def test_list_session_ids_and_jsonl_tail(tmp_path: Path) -> None:
home = tmp_path / ".clm"
ensure_clm_layout(home)
assert list_session_ids(home) == []
save_session_state(
home,
SessionState(session_id="a", goal="g"),
)
save_session_state(
home,
SessionState(session_id="b", goal="g"),
)
assert list_session_ids(home) == ["a", "b"]
jpath = home / "sessions" / "a" / "decisions.jsonl"
jpath.write_text(
'{"n":1}\n{"n":2}\n{"n":3}\n',
encoding="utf-8",
)
tail = read_jsonl_tail(jpath, n=2)
assert tail == [{"n": 2}, {"n": 3}]
assert read_jsonl_tail(home / "nope.jsonl") == []
def test_parse_profile_and_build_config(tmp_path: Path) -> None:
from clm.models.config import TaskType
assert parse_profile("coding") == TaskType.CODING
assert parse_profile("Tool-Heavy") == TaskType.TOOL_HEAVY
with pytest.raises(ValueError, match="Unknown profile"):
parse_profile("nope")
home = tmp_path / ".clm"
cfg = build_config(
"coding",
clm_home=home,
soft_compact=1000,
hard_handoff=2000,
memory_backend="file",
)
assert cfg.clm_home == home
assert cfg.thresholds.soft_compact == 1000
assert cfg.thresholds.hard_handoff == 2000
assert cfg.memory_backend == "file"
@@ -4,9 +4,9 @@ from __future__ import annotations
from collections.abc import Sequence
from clm.agents.simulated import SimulatedAgent
from clm.models.session import AgentStepResult, Message, MessageRole, SessionState, TaskStatus
from clm.session.capabilities import ExternalSessionCapable
from clm.session.simulated import SimulatedAgent
from clm.session.tracking import TrackingWorkingSession
+116
View File
@@ -0,0 +1,116 @@
"""Unit tests for shared test fakes in tests.support.fakes."""
from __future__ import annotations
from collections.abc import Sequence
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.factory import register_agent, registered_agents
from tests.support.fakes import DictMemoryStore, FakeAgent, agent_registry_snapshot
def test_fake_agent_default_step_completed(fake_agent: FakeAgent) -> None:
result = fake_agent.step([])
assert result.task_status == TaskStatus.COMPLETED
assert result.notes == "fake default"
assert result.messages_added == []
def test_fake_agent_script_steps() -> None:
script = [
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="one")],
task_status=TaskStatus.RUNNING,
),
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="two")],
task_status=TaskStatus.COMPLETED,
),
]
agent = FakeAgent(script)
r1 = agent.step([])
r2 = agent.step([])
r3 = agent.step([])
assert r1.messages_added[0].content == "one"
assert r2.task_status == TaskStatus.COMPLETED
assert r3.notes == "script exhausted"
def test_fake_agent_callable_script() -> None:
def script(messages: Sequence[Message]) -> AgentStepResult:
return AgentStepResult(
messages_added=[
Message(role=MessageRole.ASSISTANT, content=f"n={len(messages)}")
],
task_status=TaskStatus.RUNNING,
)
agent = FakeAgent(script)
result = agent.step([Message(role=MessageRole.USER, content="hi")])
assert result.messages_added[0].content == "n=1"
def test_fake_agent_inject_and_reset(fake_agent: FakeAgent) -> None:
fake_agent.inject_context("hint-a")
fake_agent.inject_context("hint-b")
assert fake_agent.system_addons == ["hint-a", "hint-b"]
seed = [Message(role=MessageRole.SYSTEM, content="seed")]
fake_agent.reset_with_seed(seed)
assert fake_agent.reset_called is True
assert fake_agent.seed_messages == seed
def test_dict_memory_write_read_list(dict_memory: DictMemoryStore) -> None:
dict_memory.write("decisions/auth", "Use JWT", tags=["security"])
assert dict_memory.read("decisions/auth") == "Use JWT"
assert dict_memory.read("missing") is None
assert "decisions/auth" in dict_memory.list_keys()
def test_dict_memory_search(dict_memory: DictMemoryStore) -> None:
dict_memory.write("a", "Alpha token one", tags=["t1"])
dict_memory.write("b", "beta token two")
dict_memory.write("c", "unrelated")
hits = dict_memory.search("ALPHA")
assert len(hits) == 1
assert hits[0].key == "a"
assert hits[0].tags == ["t1"]
assert "Alpha" in hits[0].content
limited = dict_memory.search("token", limit=1)
assert len(limited) == 1
def test_dict_memory_overwrite() -> None:
store = DictMemoryStore()
store.write("k", "v1", tags=["t1"])
store.write("k", "v2", tags=["t2"])
assert store.read("k") == "v2"
hits = store.search("v2")
assert hits and hits[0].tags == ["t2"]
def test_agent_registry_snapshot_restores() -> None:
from clm.session import factory as factory_mod
before_keys = set(factory_mod._REGISTRY)
assert "ephemeral-fake" not in before_keys
with agent_registry_snapshot():
register_agent("ephemeral-fake", lambda config, **kw: FakeAgent())
assert "ephemeral-fake" in registered_agents()
assert "ephemeral-fake" in factory_mod._REGISTRY
assert "ephemeral-fake" not in factory_mod._REGISTRY
assert set(factory_mod._REGISTRY) == before_keys
assert "simulated" in registered_agents()
def test_agent_registry_fixture(agent_registry: None) -> None:
"""agent_registry fixture isolates mutations for the duration of the test."""
from clm.session import factory as factory_mod
register_agent("fixture-only-agent", lambda config, **kw: FakeAgent())
assert "fixture-only-agent" in factory_mod._REGISTRY
# restoration is verified by the next test / full suite isolation
-94
View File
@@ -1,94 +0,0 @@
"""Outer loop wires LiveEventCapable → progress doing=."""
from __future__ import annotations
from pathlib import Path
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
from clm.logging.audit import AuditLogger
from clm.loop.control import OuterControlLoop
from clm.loop.progress import ProgressEvent, ProgressKind
from clm.memory.file_store import FileMemoryStore
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import AgentSettings, ClmConfig, Thresholds
from clm.models.session import MessageRole, SessionState, TaskStatus
from clm.session.grok_build import GrokBuildAgent
from clm.session.grok_cli import FakeGrokCliRunner
from clm.session.tracking import TrackingWorkingSession
def test_loop_emits_live_status_from_stream_events(tmp_path: Path) -> None:
clm_home = tmp_path / ".clm"
clm_home.mkdir()
store = FileMemoryStore(clm_home / "memory")
events = [
{
"type": "tool_call",
"toolCallId": "c1",
"toolName": "read_file",
"title": "Read",
"status": "in_progress",
"rawInput": {"path": "foo.py"},
},
{"type": "text", "data": "all good\nCLM_TASK_STATUS: completed"},
{
"type": "end",
"stopReason": "end_turn",
"sessionId": "live-1",
"num_turns": 1,
"usage": {
"input_tokens": 20,
"output_tokens": 5,
"cache_read_input_tokens": 0,
"total_tokens": 25,
},
},
]
fake = FakeGrokCliRunner(stream_scripts=[events])
agent = GrokBuildAgent(
runner=fake,
cwd=tmp_path,
goal="finish",
stream=True,
)
state = SessionState(
session_id="s-live",
goal="finish",
status=TaskStatus.RUNNING,
)
session = TrackingWorkingSession(agent, state)
cfg = ClmConfig(
agent_kind="grok-build",
agent=AgentSettings(cwd=tmp_path, max_turns_per_step=4, yolo=True),
thresholds=Thresholds(soft_compact=500_000, hard_handoff=600_000),
)
progress: list[ProgressEvent] = []
manifests = clm_home / "manifests"
manifests.mkdir(parents=True, exist_ok=True)
loop = OuterControlLoop(
session=session,
monitor=MetricsMonitor(clm_home / "m.jsonl"),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
store=store, config=cfg, manifests_dir=manifests
),
audit=AuditLogger(clm_home / "d.jsonl"),
config=cfg,
progress_callback=progress.append,
progress_heartbeat_s=0, # disable timer; we care about event STATUS
)
result = loop.run(max_steps=3)
assert result.stopped_reason == "completed"
status_events = [e for e in progress if e.kind == ProgressKind.STATUS]
# At least one STATUS from live tool_call
live_doings = [e.fields.get("doing") for e in status_events if e.fields.get("doing")]
assert any(d and "read_file" in str(d) for d in live_doings)
assert any(
e.fields.get("activity") == "agent_streaming" for e in status_events
)
# assistant message preserved
assert any(m.role == MessageRole.ASSISTANT for m in session.state.messages)