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:
1 parent
b7db73a2c9
commit
c5d495f830
85 files changed
+2450
-579
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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/`
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`).
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.
@@ -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.
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared test support: fakes and fixtures for CLM unit/integration tests."""
|
||||
@@ -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,
|
||||
+3
-3
@@ -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(
|
||||
[
|
||||
+4
-4
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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:
|
||||
@@ -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.
File renamed without changes.
File renamed without changes.
@@ -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"
|
||||
File renamed without changes.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user