From 47ffdfec95904846731471b214709fb37c97484e Mon Sep 17 00:00:00 2001 From: Mohammad Ausaf Date: Sat, 4 Jul 2026 01:09:21 +0530 Subject: [PATCH 1/2] Add cfgit agent coordination plugin --- .github/workflows/ci.yml | 6 +- README.md | 33 +- docs/AGENTS.md | 58 + docs/AGENT_COORDINATION_SPEC.md | 1202 +++++++++++++++++ docs/PUBLISHING.md | 15 +- docs/README.md | 1 + plugins/cfg_agent/README.md | 89 ++ plugins/cfg_agent/cfg_agent/__init__.py | 19 + plugins/cfg_agent/cfg_agent/actions.py | 130 ++ .../cfg_agent/cfg_agent/adapters/__init__.py | 5 + plugins/cfg_agent/cfg_agent/adapters/mongo.py | 255 ++++ .../cfg_agent/cfg_agent/adapters/postgres.py | 326 +++++ plugins/cfg_agent/cfg_agent/config.py | 147 ++ plugins/cfg_agent/cfg_agent/coordinator.py | 631 +++++++++ plugins/cfg_agent/cfg_agent/mcp.py | 225 +++ plugins/cfg_agent/cfg_agent/patches.py | 105 ++ plugins/cfg_agent/cfg_agent/policy.py | 133 ++ plugins/cfg_agent/cfg_agent/resources.py | 70 + plugins/cfg_agent/cfg_agent/state.py | 259 ++++ plugins/cfg_agent/pyproject.toml | 32 + pyproject.toml | 2 +- skills/cfgit/SKILL.md | 27 + src/cfg/ui/server.py | 194 ++- tests/test_agent_coordination.py | 908 +++++++++++++ tests/test_core_purity.py | 10 + tests/test_ui_server.py | 10 + 26 files changed, 4885 insertions(+), 7 deletions(-) create mode 100644 docs/AGENT_COORDINATION_SPEC.md create mode 100644 plugins/cfg_agent/README.md create mode 100644 plugins/cfg_agent/cfg_agent/__init__.py create mode 100644 plugins/cfg_agent/cfg_agent/actions.py create mode 100644 plugins/cfg_agent/cfg_agent/adapters/__init__.py create mode 100644 plugins/cfg_agent/cfg_agent/adapters/mongo.py create mode 100644 plugins/cfg_agent/cfg_agent/adapters/postgres.py create mode 100644 plugins/cfg_agent/cfg_agent/config.py create mode 100644 plugins/cfg_agent/cfg_agent/coordinator.py create mode 100644 plugins/cfg_agent/cfg_agent/mcp.py create mode 100644 plugins/cfg_agent/cfg_agent/patches.py create mode 100644 plugins/cfg_agent/cfg_agent/policy.py create mode 100644 plugins/cfg_agent/cfg_agent/resources.py create mode 100644 plugins/cfg_agent/cfg_agent/state.py create mode 100644 plugins/cfg_agent/pyproject.toml create mode 100644 tests/test_agent_coordination.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ecc5ba..478479d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: python -m pip install -U pip python -m pip install -e '.[mongo,postgres,mcp,dev]' python -m pip install -e plugins/cfg_impact + python -m pip install -e plugins/cfg_agent - name: Run tests run: python -m pytest -q @@ -38,9 +39,12 @@ jobs: - name: Build and check distributions run: | python -m pip install -U build twine - rm -rf dist plugins/cfg_impact/dist + rm -rf dist plugins/cfg_impact/dist plugins/cfg_agent/dist python -m build python -m twine check dist/* cd plugins/cfg_impact python -m build python -m twine check dist/* + cd ../cfg_agent + python -m build + python -m twine check dist/* diff --git a/README.md b/README.md index 0b6bc0e..a356c6a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ cfgit is pre-1.0 software. The current implementation includes: - MCP server - portable Codex or Claude Code skill - optional `cfgit-impact` plugin for deterministic impact summaries and opt-in LLM narration +- optional `cfgit-agent` plugin for multi-agent coordination over shared live database records The engine is intentionally DB-neutral. Mongo and Postgres are the first two adapters to prove the storage seam. @@ -130,6 +131,34 @@ Optional impact plugin: pip install cfgit-impact ``` +Agent coordination plugin is currently in-repo development, not published to +PyPI yet: + +```bash +pip install -e plugins/cfg_agent +``` + +Enable it per project when you want multi-agent coordination state: + +```toml +[agent] +enabled = true +state_backend = "auto" # memory, auto, mongo, or postgres +state_collection = "cfgit_agent_state" +events_collection = "cfgit_agent_events" +default_lease_ttl_seconds = 900 + +[agent.policies] +deny_paths = ["/provider_config*"] +review_paths = ["/rollout*", "/pricing*"] +require_claims = true +``` + +When an agent patch touches `review_paths`, `cfgit-agent` validates the patch, +creates a cfgit draft branch, opens a cfgit PR, and returns +`state = "review_requested"` without mutating runtime. The human merge path +remains the only runtime mutation for those changes. + If you use `pipx`, install cfgit first and inject the optional plugin into the same isolated environment: @@ -358,7 +387,9 @@ surfaces live drift and the latest cfgit commits across all configured records, so you can see what changed recently without opening records one by one. It can run status, diff, impact, commit, branch draft commits, PR open and merge, log, show, adopt, restore, tag, init, import, and fsck, and ships dark and light -themes. +themes. When `cfgit-agent` is installed and `[agent]` is enabled, the UI also +shows live agent sessions, claims, intents, conflicts, and events, with safe +manager actions for stale coordination state. By default it binds to `127.0.0.1:8765` and tries the next free ports if needed: diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 636758b..680409e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -157,3 +157,61 @@ Supported providers: The impact engine calls a provider-agnostic `narrate()` or `complete()` method. Provider selection is done by `cfg_impact.providers.factory.ImpactProviderFactory`. No vendor provider code lives in `src/cfg/core`. + +## Agent coordination plugin + +`cfgit-agent` is an optional package for multi-agent coordination over shared +live database records. It keeps cfgit core lightweight while adding agent-first +sessions, leases, intents, idempotency, conflicts, and event feeds. + +It is currently in-repo development, not published to PyPI yet: + +```bash +pip install -e plugins/cfg_agent +``` + +Current implementation slice: + +- package scaffold +- in-memory state adapter +- Mongo and Postgres state adapters +- plugin-local `[agent]` config loader +- resource/path overlap checks +- sessions +- field/record/collection leases +- intents +- idempotency records +- structured conflicts +- deterministic claim/path policies +- branch/PR routing for review-required paths +- JSON Patch validation against cfgit HEAD/live state +- safe patch application through cfgit core +- MCP wrappers + +The current apply path goes through cfgit core's normal commit/apply safety +checks rather than writing the database directly. Agent state is opt-in; if +`[agent].enabled = true`, use `state_backend = "auto"` to store sessions, leases, +intents, conflicts, idempotency, and events beside cfgit history. +If `[agent.policies].review_paths` matches a patch path, `apply_patch` does not +mutate runtime; it creates a cfgit branch draft commit, opens a cfgit PR, links +the PR to the agent session/intent, and returns `review_requested`. +The localhost UI includes an agent manager view for enabled projects, so humans +can inspect active sessions, claims, intents, conflicts, and events without +tailing MCP logs. + +Minimal config: + +```toml +[agent] +enabled = true +state_backend = "auto" +state_collection = "cfgit_agent_state" +events_collection = "cfgit_agent_events" + +[agent.policies] +deny_paths = ["/provider_config*"] +review_paths = ["/rollout*", "/pricing*"] +require_claims = true +``` + +See [AGENT_COORDINATION_SPEC.md](AGENT_COORDINATION_SPEC.md). diff --git a/docs/AGENT_COORDINATION_SPEC.md b/docs/AGENT_COORDINATION_SPEC.md new file mode 100644 index 0000000..8e1d504 --- /dev/null +++ b/docs/AGENT_COORDINATION_SPEC.md @@ -0,0 +1,1202 @@ +# cfgit-agent: Agent Coordination Spec + +> Status: design spec for an optional package in this repository. This is not a +> replacement for cfgit core. It describes an agent-first coordination layer on +> top of cfgit's existing non-custodial database version-control engine. + +--- + +## 1. Definition + +`cfgit-agent` is an optional coordination and safety layer for multiple agents +working against the same live database records. + +It gives agents first-class primitives for: + +- sessions +- claims / leases +- declared intent +- safe patches +- idempotency +- conflict reporting +- event feeds +- human operator review + +The core sentence: + +> cfgit-agent lets multiple agents safely coordinate changes to live database +> records with claims, intents, patches, conflicts, audit, and rollback. + +The package must stay optional: + +```bash +pip install cfgit-agent +``` + +and must not make `cfgit` core an agent runtime. + +--- + +## 2. Product Boundary + +cfgit core remains: + +> Non-custodial version control for live database records. + +cfgit-agent becomes: + +> Agent-first coordination for shared live database state. + +This distinction matters. Agent frameworks already handle planning, tool +calling, durable execution, retries, memory, and model orchestration. cfgit-agent +does not compete with them. It coordinates the risky part they often leave to a +generic database tool call: several autonomous or semi-autonomous workers reading +and mutating the same live operational records. + +### Non-goals + +- No agent planner. +- No task scheduler. +- No model router. +- No workflow runtime. +- No hosted queue. +- No replacement for LangGraph, Temporal, CrewAI, AutoGen, or MCP servers. +- No mandatory write proxy for the application. +- No changes to the application's runtime read path. + +### Hard boundary + +`src/cfg/core/` must not import `cfgit-agent`. + +The package lives outside core, likely: + +```text +plugins/cfg_agent/ + pyproject.toml + cfg_agent/ + __init__.py + actions.py + config.py + state.py + policies.py + mcp.py + ui.py + patches.py +``` + +The package depends on `cfgit>=0.1.x,<0.2.0` and uses public engine/action +interfaces. Core can expose small generic extension seams if needed, but agent +logic stays outside core. + +--- + +## 3. The Problem + +In a multi-agent environment, "database access" is too coarse. + +An agent that can update a record can accidentally: + +- overwrite another agent's work +- retry the same mutation twice +- edit stale state after a human or agent moved the base +- mutate a field outside its responsibility +- make a high-risk runtime behavior change without review +- leave no clear task-level trace tying reads, plans, writes, and rollback + +cfgit core already gives history, drift detection, rollback, branch/PR review, +and impact. cfgit-agent adds the missing operational protocol agents should +follow before reaching for a write: + +```text +announce -> claim -> inspect -> declare intent -> propose patch -> validate +-> apply/commit -> release -> close session +``` + +The goal is not to block speed. The goal is to make the safe path faster than an +ad-hoc raw write. + +--- + +## 4. First-Class Actors + +### Agent + +An agent is the primary user of cfgit-agent. It is expected to call MCP tools or +CLI/JSON actions repeatedly during a task. + +Agent needs: + +- know whether it can work on a record +- avoid conflicting with other active agents +- declare what it is about to change +- get precise blockers when unsafe +- retry safely +- leave a trace a human can understand + +### Human Operator + +The human is the manager, reviewer, and recovery owner. + +Human needs: + +- see active sessions +- see who claims what +- understand open intents +- approve or reject high-risk changes +- take over expired work +- inspect conflicts +- restore/adopt using cfgit core when needed + +The human should manage flow, not micromanage every tool call. + +--- + +## 5. Opt-In Configuration + +Agent coordination is disabled by default. + +```toml +[agent] +enabled = true +state_backend = "auto" # memory, auto, mongo, or postgres +state_collection = "cfgit_agent_state" +events_collection = "cfgit_agent_events" +default_lease_ttl_seconds = 900 + +[agent.policies] +deny_paths = ["/provider_config*", "/secrets*"] +review_paths = ["/rollout*", "/pricing*"] +require_claims = true + +[[agent.role]] +name = "routing-agent" +can_claim = ["modelgarden_models:*", "routing_rules:*"] + +[[agent.role]] +name = "prompt-agent" +can_claim = ["agent_configs:*"] +deny_paths = ["/provider_config*"] +review_paths = ["/instructions*"] +``` + +The same config should work for Mongo and Postgres. Adapter-specific storage +details are hidden behind an agent-state adapter. `state_backend = "auto"` uses +the active cfgit env database type; `memory` is for local single-process testing. + +--- + +## 6. Sidecar State Model + +V1 should minimize new collections/tables. + +Preferred V1 storage: + +```text +cfgit_agent_state +cfgit_agent_events +``` + +`cfgit_agent_state` stores current mutable coordination objects with a `kind` +field: + +```json +{ "kind": "session", ... } +{ "kind": "lease", ... } +{ "kind": "intent", ... } +{ "kind": "conflict", ... } +{ "kind": "idempotency", ... } +``` + +`cfgit_agent_events` is append-only: + +```json +{ "event": "session.started", ... } +{ "event": "lease.acquired", ... } +{ "event": "intent.opened", ... } +{ "event": "patch.validated", ... } +{ "event": "patch.applied", ... } +{ "event": "conflict.detected", ... } +{ "event": "session.completed", ... } +``` + +Later, if query load or indexing demands it, split state into separate tables. +Do not start there. + +--- + +## 7. Object Specs + +### 7.1 Session + +A session is one agent run or task. + +```json +{ + "kind": "session", + "session_id": "ses_01h...", + "agent_id": "agent.refund-writer", + "agent_kind": "codex|claude-code|custom|service", + "task": "Update refund policy copy", + "actor": "bot@runtime", + "status": "running", + "started_at": "2026-07-03T10:00:00Z", + "heartbeat_at": "2026-07-03T10:02:00Z", + "ended_at": null, + "tool_client": "mcp", + "metadata": {} +} +``` + +Statuses: + +```text +running | blocked | completed | failed | abandoned +``` + +Rules: + +- Every lease, intent, patch, and conflict should link to a session. +- A session must heartbeat while it owns active leases. +- Expired sessions do not automatically release leases until a cleanup action or + takeover marks the transition. This avoids silent ownership changes. + +### 7.2 Lease / Claim + +A lease is a time-bounded claim on a resource. + +Resources: + +```text +collection:id +collection:id:/json/path +collection:* +``` + +Example: + +```json +{ + "kind": "lease", + "lease_id": "lea_01h...", + "session_id": "ses_01h...", + "resource": "agent_configs:refund_resolution:/instructions", + "collection": "agent_configs", + "record_id": "refund_resolution", + "path": "/instructions", + "scope": "field", + "status": "active", + "reason": "Edit refund copy", + "created_at": "2026-07-03T10:01:00Z", + "expires_at": "2026-07-03T10:16:00Z", + "released_at": null +} +``` + +Statuses: + +```text +active | released | expired | stolen +``` + +Conflict rules: + +- record lease conflicts with any field lease on the same record +- collection lease conflicts with any record or field lease in that collection +- field leases conflict only when paths overlap +- `/a` overlaps `/a/b`; `/a/b` does not overlap `/a/c` + +### 7.3 Intent + +An intent declares planned work before mutation. + +```json +{ + "kind": "intent", + "intent_id": "int_01h...", + "session_id": "ses_01h...", + "resources": ["agent_configs:refund_resolution"], + "summary": "Lower refund automation threshold and update enterprise fallback wording.", + "planned_paths": ["/automation_threshold", "/instructions"], + "risk_level": "medium", + "expected_base": { + "agent_configs:refund_resolution": { + "head_seq": 7, + "head_oid": "sha256:..." + } + }, + "idempotency_key": "task-123:refund-policy-edit", + "status": "open", + "created_at": "2026-07-03T10:03:00Z", + "closed_at": null +} +``` + +Statuses: + +```text +open | committed | superseded | rejected | abandoned +``` + +Rules: + +- Mutating agent actions should require an open intent when + `require_intent_for_write = true`. +- Intent base must be checked again at patch validation/apply time. +- Intent does not reserve a resource by itself. Lease owns coordination; intent + owns purpose. + +### 7.4 Patch + +V1 patches should use RFC 6902 JSON Patch for precision. + +```json +{ + "record": "agent_configs:refund_resolution", + "base": { + "head_seq": 7, + "head_oid": "sha256:..." + }, + "patch": [ + { "op": "replace", "path": "/automation_threshold", "value": 0.76 }, + { "op": "replace", "path": "/instructions", "value": "..." } + ], + "idempotency_key": "task-123:patch-1", + "intent_id": "int_01h..." +} +``` + +Rules: + +- Patches are preferred over whole-document writes. +- Patch paths must be covered by active leases unless policy allows claimless + writes. +- Patch base must match cfgit HEAD and live drift state according to policy. +- If live has drifted from HEAD, validation must fail with a drift conflict + unless the intent explicitly says it is adopting drift. +- Idempotency key prevents duplicate retries. + +### 7.5 Conflict + +Conflict is a first-class object, not just an error string. + +```json +{ + "kind": "conflict", + "conflict_id": "con_01h...", + "session_id": "ses_01h...", + "resource": "agent_configs:refund_resolution:/instructions", + "type": "path_overlap", + "severity": "blocking", + "sessions": ["ses_a", "ses_b"], + "paths": ["/instructions"], + "message": "Another active lease overlaps /instructions.", + "resolution": "wait|rebase|abandon|human_override", + "status": "open", + "created_at": "2026-07-03T10:04:00Z", + "resolved_at": null +} +``` + +Types: + +```text +lease_conflict +base_moved +path_overlap +policy_block +live_drift +idempotency_replay +session_expired +human_review_required +``` + +--- + +## 8. Adapter Requirements + +cfgit-agent needs an `AgentStateAdapter` separate from cfgit core's +`StorageAdapter`. + +```python +class AgentStateAdapter(Protocol): + def init_agent_state(self) -> None: ... + + def create_session(self, session: dict) -> dict: ... + def heartbeat_session(self, session_id: str, now: datetime) -> dict: ... + def close_session(self, session_id: str, status: str, now: datetime) -> dict: ... + def get_session(self, session_id: str) -> dict | None: ... + def list_sessions(self, status: str | None = None) -> list[dict]: ... + + def acquire_lease(self, lease: dict, *, now: datetime) -> dict: ... + def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict: ... + def release_lease(self, lease_id: str, *, now: datetime) -> dict: ... + def list_leases(self, *, active_only: bool = True) -> list[dict]: ... + + def open_intent(self, intent: dict) -> dict: ... + def close_intent(self, intent_id: str, status: str, now: datetime) -> dict: ... + def get_intent(self, intent_id: str) -> dict | None: ... + def list_intents(self, status: str | None = None) -> list[dict]: ... + + def remember_idempotency(self, key: str, result: dict, now: datetime) -> dict | None: ... + def get_idempotency(self, key: str) -> dict | None: ... + + def open_conflict(self, conflict: dict) -> dict: ... + def resolve_conflict(self, conflict_id: str, resolution: str, now: datetime) -> dict: ... + def list_conflicts(self, status: str | None = None) -> list[dict]: ... + + def append_event(self, event: dict) -> None: ... + def list_events(self, *, since: datetime | None = None, limit: int = 100) -> list[dict]: ... +``` + +Atomicity requirements: + +- `acquire_lease` must be atomic against overlapping active leases. +- `remember_idempotency` must atomically return the previous result if the key + already exists. +- `append_event` should be best-effort but must never break the state mutation + it describes. If a store cannot atomically write state + event, state wins and + a later `fsck` can report missing events. + +Mongo implementation: + +- store state in `cfgit_agent_state` +- unique indexes: + - `kind + session_id` for sessions + - `kind + lease_id` for leases + - `kind + intent_id` for intents + - `kind + conflict_id` for conflicts + - `kind + idempotency_key` for idempotency +- lease overlap requires query + transaction or compare-and-set inside a + transaction + +Postgres implementation: + +- one `cfgit_agent_state` table with `kind text`, `id text`, `doc jsonb` +- one `cfgit_agent_events` table with append-only rows +- use transaction + row locks / exclusion strategy for lease conflict detection + +--- + +## 9. Safety Invariants + +These are non-negotiable. + +1. **No hidden runtime mutation.** Any agent action that changes live records must + go through cfgit core mutation paths. +2. **No stale base writes.** A patch prepared against base X must fail if HEAD or + live drift moved in a way policy says is unsafe. +3. **No claim bypass when enabled.** If `require_claim_for_write` is true, + mutating actions without an active lease fail. +4. **No intent bypass when enabled.** If `require_intent_for_write` is true, + mutating actions without an open matching intent fail. +5. **No duplicate retry writes.** Idempotency keys return the previous result. +6. **No silent conflict resolution.** Conflicts are recorded and returned as + structured data. +7. **No agent self-approval for human-review-required actions.** Agent can request + review; it cannot grant it. +8. **No LLM dependency in core or cfgit-agent required path.** LLM narration stays + optional and can be delegated to `cfgit-impact`. +9. **No cross-project DB writes by default.** The configured environment is the + only target. Agents should see and report the env/db they are mutating. + +--- + +## 10. Agent MCP Tools + +V1 MCP tools should be the primary surface. CLI can mirror them for humans and +scripts, but design the protocol around agents. + +### Session + +```text +cfg_agent_start_session(task, agent_id?, agent_kind?, metadata?) +cfg_agent_heartbeat(session_id) +cfg_agent_end_session(session_id, status, summary?) +cfg_agent_status(session_id?) +``` + +### Claims + +```text +cfg_agent_claim(session_id, resource, ttl_seconds?, reason?) +cfg_agent_release(session_id, lease_id) +cfg_agent_renew(session_id, lease_id, ttl_seconds?) +cfg_agent_claims(active_only?) +``` + +### Intents + +```text +cfg_agent_open_intent(session_id, resources, summary, planned_paths, + risk_level?, idempotency_key?) +cfg_agent_close_intent(session_id, intent_id, status) +cfg_agent_intents(status?) +``` + +### Patch Flow + +```text +cfg_agent_prepare_patch(session_id, record, patch, intent_id, idempotency_key?) +cfg_agent_validate_patch(session_id, prepared_patch_id | patch_payload) +cfg_agent_apply_patch(session_id, prepared_patch_id | patch_payload, message) +cfg_agent_commit(session_id, record, doc_or_patch, message, intent_id, + idempotency_key?) +``` + +Naming note: if V1 does not persist prepared patches, combine prepare/validate +into `cfg_agent_validate_patch` and accept the full patch payload. + +### Conflicts and Events + +```text +cfg_agent_conflicts(status?) +cfg_agent_explain_blocker(conflict_id | last_error) +cfg_agent_resolve_conflict(conflict_id, resolution, note?) +cfg_agent_watch(since?, limit?) +``` + +### Human Review + +```text +cfg_agent_request_review(session_id, intent_id, reason) +cfg_agent_reviews(status?) +cfg_agent_approve_review(review_id, note) +cfg_agent_reject_review(review_id, note) +``` + +V1 may defer review tools if core approval primitives are not ready, but the +spec should reserve the shape. + +--- + +## 11. CLI Shape + +CLI mirrors MCP for humans and scripts: + +```bash +cfg agent start-session -m "Update refund policy" --json +cfg agent claim agent_configs:refund_resolution --session ses_... --json +cfg agent intent open --session ses_... --record agent_configs:refund_resolution \ + --path /instructions --path /automation_threshold \ + -m "Lower automation threshold and clarify enterprise refund language" --json +cfg agent patch validate --session ses_... --patch patch.json --json +cfg agent patch apply --session ses_... --patch patch.json -m "update refund policy" --json +cfg agent end-session ses_... --status completed --json +``` + +All commands return the same envelope style as cfgit actions. + +--- + +## 12. Human Operator UI + +The existing local UI should grow an optional Agent tab when `[agent].enabled`. + +Views: + +### Active Sessions + +Rows: + +- agent id +- task +- status +- heartbeat age +- claims count +- open intents count +- blocked/conflict count + +Actions: + +- inspect +- mark abandoned +- release expired leases +- request summary + +### Claims + +Rows: + +- resource +- owner session +- scope +- expires in +- reason + +Actions: + +- release own claim +- takeover expired claim +- inspect conflicting claims + +### Intents + +Rows: + +- summary +- resources +- planned paths +- risk +- status +- linked impact + +Actions: + +- approve/reject if review is enabled +- inspect diff/impact +- open related record + +### Conflicts + +Rows: + +- type +- resource +- sessions involved +- blocker message +- suggested resolution + +Actions: + +- wait +- release stale lease +- request rebase +- human override + +### Event Feed + +Timeline: + +- session started +- claim acquired +- intent opened +- patch validated +- commit applied +- conflict detected +- restore/adopt from core + +The UI is observational and operational. It should not become a hosted control +plane in V1. + +--- + +## 13. Patch Validation Algorithm + +Input: + +- session id +- target record +- base seq/oid +- JSON Patch +- intent id +- idempotency key + +Steps: + +1. Load session. It must be `running` or `blocked` with resumable status. +2. Check idempotency key. If present and already completed, return previous + result. +3. Load active leases for session and target. +4. Check each patch path is covered by a compatible lease. +5. Load intent. It must be open and include the target resource. +6. Check patch paths are a subset of `planned_paths` unless policy allows + expansion. +7. Load cfgit HEAD and live status. +8. If HEAD moved from expected base, fail with `base_moved`. +9. If live drift exists and policy disallows drifted base, fail with + `live_drift`. +10. Apply JSON Patch to the HEAD document in memory. +11. Run cfgit diff against base. +12. Run policy hooks: + - denied paths + - allowed role paths + - secret-shaped values + - required impact + - required human review +13. Return: + - `ok` + - `needs_review` + - `conflict` + - `policy_block` + +No live write happens in validate. + +--- + +## 14. Apply Algorithm + +Input: + +- validated patch payload or prepared patch id +- message +- session id +- intent id +- idempotency key + +Steps: + +1. Re-run validation. Never trust an older validation result. +2. If validation result is not `ok`, return it and create/update conflict. +3. Build the patched full document. +4. Call cfgit core commit/apply path with base checks. +5. Store idempotency result. +6. Mark intent `committed` if all resources are done. +7. Append events: + - `patch.applied` + - `commit.created` +8. Return cfgit commit result plus agent metadata. + +Open question for V1: whether `apply_patch` should commit directly or create a +cfgit branch draft commit. Recommendation: V1 direct-commits to runtime only in +dev/open environments and uses branch/PR flow in restricted/high-risk +environments. + +--- + +## 15. Relationship To cfgit Branches / PRs + +Agent coordination and cfgit branches solve different problems. + +Claims/intents answer: + +> Who is working on what right now, and are they allowed to touch it? + +Branches/PRs answer: + +> What proposed change should be reviewed before runtime mutation? + +Recommended mapping: + +- low-risk dev edit: claim + intent + validate + direct commit +- medium-risk edit: claim + intent + validate + branch draft commit +- high-risk/prod edit: claim + intent + validate + branch draft commit + PR + + human approval + merge + +Agent plugin should not invent a second PR system. It should call cfgit branch +and PR actions when review is needed. + +--- + +## 16. Policy Hooks + +Policy hooks are local deterministic checks. + +V1 built-ins: + +- `require_session` +- `require_claim` +- `require_intent` +- `path_allowlist` +- `path_denylist` +- `secret_value_block` +- `base_must_match` +- `no_live_drift` +- `require_impact` +- `require_human_review` + +Plugin hook shape: + +```python +class AgentPolicyHook(Protocol): + name: str + + def evaluate(self, ctx: AgentPolicyContext) -> AgentPolicyResult: ... +``` + +Result: + +```json +{ + "status": "ok|warn|block|needs_review", + "code": "path_denied", + "message": "routing-agent cannot edit /provider_config", + "details": {} +} +``` + +Policy hooks must be deterministic. If an LLM is used to explain a policy result, +that explanation is non-authoritative. + +--- + +## 17. Idempotency + +Agents retry. The system must assume duplicate tool calls. + +Idempotency key scope: + +```text +env + session_id + idempotency_key +``` + +Rules: + +- If a key is seen with the same normalized payload, return the previous result. +- If a key is seen with a different payload, block with `idempotency_conflict`. +- Store enough result metadata for agents to resume without guessing. +- Expire idempotency entries after configured window. + +--- + +## 18. Event Feed + +Events are for both humans and agents. + +Event shape: + +```json +{ + "event_id": "evt_01h...", + "event": "lease.acquired", + "session_id": "ses_01h...", + "actor": "bot@runtime", + "resource": "agent_configs:refund_resolution", + "recorded_at": "2026-07-03T10:00:00Z", + "details": {} +} +``` + +V1 watch is polling: + +```text +cfg_agent_watch(since_event_id?, limit?) +``` + +Future watch can be SSE/websocket in the local UI or MCP resource subscription +if the client supports it. + +--- + +## 19. Failure Modes + +### Agent dies with active lease + +Lease expires. UI shows stale owner. Another session can request takeover. +Takeover records: + +- old session +- new session +- reason +- whether old lease was expired + +### Agent retries after successful apply + +Idempotency returns previous commit result. + +### Agent validates, then another writer changes live DB + +Apply re-runs validation and fails with `live_drift` or `base_moved`. + +### Two agents claim sibling fields + +Allowed if paths do not overlap and policy allows field claims. + +### Two agents claim same record, different fields, but commit whole docs + +Block. Whole-doc writes require record lease. Field leases require patch-based +apply. + +### Human edits DB directly + +cfgit core drift detection catches it. cfgit-agent reports open sessions whose +base is now stale. + +### Agent attempts prohibited path + +Policy block with exact path and role reason. No mutation. + +--- + +## 20. Package / Release Shape + +Same repository, separate PyPI package: + +```text +cfgit +cfgit-impact +cfgit-agent +``` + +Why: + +- cfgit core stays lightweight +- agent coordination can iterate quickly +- users do not install agent surfaces unless they need them +- sidecar state is explicit and opt-in +- release workflow can publish all packages from one repo + +V1 package metadata: + +```toml +[project] +name = "cfgit-agent" +version = "0.1.0" +dependencies = [ + "cfgit>=0.1.2,<0.2.0" +] +``` + +Optional extras if needed: + +```toml +[project.optional-dependencies] +mcp = ["mcp>=1.0"] +dev = ["pytest>=8.0", "ruff", "mypy"] +``` + +--- + +## 21. V1 Build Order + +### Stage 0: Spec and package scaffold + +- Add `plugins/cfg_agent/` +- Add package metadata +- Add README +- Add import boundary tests +- Add docs index links + +Acceptance: + +- `python -m build` works for `cfgit-agent` +- core purity tests prove no import from core to agent package + +### Stage 1: State adapter and models + +- Agent config parser +- In-memory test adapter +- Mongo state adapter +- Postgres state adapter if cheap; otherwise after Mongo +- session CRUD +- event append/list + +Acceptance: + +- start/end/heartbeat session tests +- event feed tests +- Mongo local integration tests if available + +### Stage 2: Leases + +- resource parser +- path overlap logic +- acquire/renew/release/list +- expiration and takeover semantics +- conflict object for lease conflict + +Acceptance: + +- record vs field conflict tests +- non-overlapping field lease tests +- expired lease takeover tests +- idempotent release tests + +### Stage 3: Intents and idempotency + +- open/close/list intents +- expected base capture +- idempotency storage +- idempotency replay/conflict checks + +Acceptance: + +- duplicate retry returns previous result +- duplicate key with different payload blocks +- intent path expansion policy tested + +### Stage 4: Patch validation + +- JSON Patch apply in memory +- claim coverage check +- intent coverage check +- base/head/live checks via cfgit core +- built-in policies + +Acceptance: + +- safe patch validates +- stale base fails +- live drift fails +- denied path fails +- missing claim/intent fails when required + +Current implementation status: validation is implemented for sessions, claims, +intents, base movement, live drift, and JSON Patch application. + +### Stage 5: Patch apply / commit + +- Re-run validation at apply time +- Call cfgit core commit path +- Link commit result to session/intent/event +- Release optional auto-release behavior behind config + +Acceptance: + +- successful patch creates cfgit history +- retry returns same result +- race between validate/apply fails closed + +Current implementation status: safe apply is implemented against cfgit core's +commit path with validation re-run, intent closure, idempotent replay, and +events. In-memory, Mongo, and Postgres agent-state adapters are implemented, as +are deterministic policy hooks for claims, denied paths, and configured secret +fields. + +### Stage 6: MCP tools + +- Expose session tools +- Expose claim tools +- Expose intent tools +- Expose validate/apply tools +- Expose conflicts/watch tools + +Acceptance: + +- MCP tests assert payload forwarding and action envelopes +- skill docs include agent-first workflow + +### Stage 7: UI agent tab + +- Active sessions +- Claims +- Intents +- Conflicts +- Event feed +- Buttons for safe human actions + +Acceptance: + +- UI server tests for rendered controls +- browser smoke when connector is available + +Current implementation status: the localhost UI includes an optional agent +coordination manager. It reports whether `[agent]` is enabled, shows sessions, +claims, intents, conflicts, and events, and exposes safe human actions for +ending sessions, releasing leases, and abandoning open intents. + +### Stage 8: Branch/PR integration + +- Policy can route patch to direct commit or branch draft +- PR review can link back to agent intent/session +- Human merge remains the only high-risk runtime mutation + +Acceptance: + +- high-risk policy creates branch/PR instead of direct runtime commit +- merge checks stale base and drift + +Current implementation status: review-required paths are supported through +`[agent.policies].review_paths` or per-role `review_paths`. `apply_patch` +validates the patch, creates a cfgit draft branch commit, opens a cfgit PR, +links the PR to the agent session/intent, closes the intent as +`review_requested`, and does not mutate runtime. Runtime mutation remains cfgit +PR merge. + +--- + +## 22. V1 Cut Line + +Build V1: + +- package scaffold +- config +- sessions +- leases +- intents +- idempotency +- JSON Patch validation +- JSON Patch apply through cfgit core +- structured conflicts +- MCP tools +- basic UI tab + +Do not build in V1: + +- scheduler +- autonomous planner +- hosted service +- queue workers +- LLM judge policies +- distributed lock service beyond DB-backed leases +- websocket/SSE watch +- complex automatic merge +- cross-database transaction coordination + +--- + +## 23. Acceptance Scenario + +Simulate three agents and one human operator. + +Initial state: + +- `agent_a` edits `/instructions` on `agent_configs:refund_resolution` +- `agent_b` edits `/automation_threshold` on the same record +- `agent_c` attempts `/provider_config/api_key` +- human watches UI + +Expected: + +1. `agent_a` starts session and claims `/instructions`. +2. `agent_b` starts session and claims `/automation_threshold`. +3. Both claims succeed because paths do not overlap. +4. `agent_c` tries to claim or edit `/provider_config/api_key`. +5. Policy blocks `agent_c`. +6. `agent_a` opens intent, validates patch, applies patch. +7. `agent_b` validates against stale base after `agent_a` commits. +8. `agent_b` receives `base_moved` with rebase guidance. +9. Human sees: + - two active/completed sessions + - one policy block conflict + - one stale-base conflict + - one applied cfgit commit +10. Rollback remains available through cfgit core. + +This scenario is the minimum proof that cfgit-agent is not just logging. It +actively coordinates non-overlapping work, blocks unsafe paths, detects stale +bases, and keeps human-visible audit. + +--- + +## 24. Open Questions + +1. Should V1 require JSON Patch only, or also support merge patch? + - Recommendation: JSON Patch only. It maps cleanly to field claims. + +2. Should field claims be enabled by default? + - Recommendation: yes for agent mode, because whole-record claims are too + coarse for parallel agents. + +3. Should direct apply be allowed in production? + - Recommendation: configurable, default false for restricted/prod envs. Route + through cfgit branch/PR instead. + +4. Should sessions auto-close when all leases released and intents committed? + - Recommendation: no. Agents should close explicitly; cleanup can mark stale + sessions abandoned. + +5. Should human review live in cfgit-agent or cfgit core? + - Recommendation: cfgit core owns generic approval primitives; cfgit-agent + owns agent-specific review requests and links them to sessions/intents. + +6. Should agent state be versioned by cfgit itself? + - Recommendation: no. Agent state is operational metadata. Event feed is the + audit. cfgit history is for the user-configured live records. + +--- + +## 25. Design Principle + +Agent mode should make the correct thing obvious: + +```text +I am agent X. +I am working on Y. +I intend to change Z. +Here is the patch. +Here is the validation. +Here is the commit or blocker. +Here is how a human can recover. +``` + +That protocol is the product. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 92ed80f..b26a712 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -1,12 +1,16 @@ # Publishing -cfgit publishes two Python distributions: +cfgit currently publishes two Python distributions: - `cfgit`: Git-style history, diff, drift detection, branch/PR review, and rollback for live database records without migrating or owning the datastore. - `cfgit-impact`: optional plugin for deterministic system-impact summaries and opt-in LLM narration of database record diffs. +The repository also contains `cfgit-agent`, an optional package for multi-agent +coordination over live database records. It is not included in the release +workflow until its PyPI project and trusted publisher are configured. + Current release version: `0.1.2`. ## One-Time PyPI Setup @@ -35,13 +39,16 @@ Secrets. From the repository root: ```bash -python -m pip install -U build twine -rm -rf dist plugins/cfg_impact/dist +python -m pip install -U build twine hatchling +rm -rf dist plugins/cfg_impact/dist plugins/cfg_agent/dist python -m build python -m twine check dist/* cd plugins/cfg_impact python -m build python -m twine check dist/* +cd ../cfg_agent +python -m build +python -m twine check dist/* ``` ## Clean Install Smoke @@ -79,4 +86,6 @@ pip install 'cfgit[mongo]' pip install 'cfgit[postgres]' pip install 'cfgit[mongo,postgres,mcp]' pip install cfgit-impact +# After cfgit-agent is published: +# pip install cfgit-agent ``` diff --git a/docs/README.md b/docs/README.md index efca3a2..1f996dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ Start with the user-facing guides: - [Identity & Attribution](IDENTITY_AND_ATTRIBUTION.md): open, authenticated, and enforced identity modes. - [Adapters](ADAPTERS.md): Mongo and Postgres storage details. - [Agents](AGENTS.md): MCP, skill, and impact plugin usage. +- [Agent coordination spec](AGENT_COORDINATION_SPEC.md): optional `cfgit-agent` package for multi-agent database write coordination. Technical references: diff --git a/plugins/cfg_agent/README.md b/plugins/cfg_agent/README.md new file mode 100644 index 0000000..5bd90bf --- /dev/null +++ b/plugins/cfg_agent/README.md @@ -0,0 +1,89 @@ +# cfgit-agent + +Optional agent coordination for cfgit. + +This package is deliberately outside `src/cfg/core/`. cfgit core remains +non-custodial version control for live database records; cfgit-agent adds +agent-first coordination primitives around that core. + +## Install + +Current development install from this repository: + +```bash +pip install -e plugins/cfg_agent +``` + +Once published, install it into the same environment as cfgit: + +```bash +pip install cfgit-agent +pip install 'cfgit-agent[mongo,mcp]' +``` + +## What It Adds + +- agent sessions +- resource claims / leases +- declared intents +- structured conflicts +- idempotency records +- event feed +- in-memory, Mongo, and Postgres coordination state adapters +- deterministic policy hooks for claim/path guardrails +- MCP tools for agent-first workflows + +It does not plan tasks, schedule agents, route models, or replace LangGraph, +Temporal, AutoGen, CrewAI, or MCP database servers. + +## Basic Flow + +```text +start session -> claim resource -> open intent -> validate/apply patch +-> release claim -> end session +``` + +## Configuration + +Agent coordination is disabled unless explicitly enabled: + +```toml +[agent] +enabled = true +state_backend = "auto" # memory, auto, mongo, or postgres +state_collection = "cfgit_agent_state" +events_collection = "cfgit_agent_events" +default_lease_ttl_seconds = 900 + +[agent.policies] +deny_paths = ["/provider_config*"] +review_paths = ["/rollout*", "/pricing*"] +require_claims = true + +[[agent.role]] +name = "routing-agent" +can_claim = ["modelgarden_models:*", "routing_rules:*"] + +[[agent.role]] +name = "prompt-agent" +can_claim = ["agent_configs:*"] +deny_paths = ["/provider_config*"] +review_paths = ["/instructions*"] +``` + +`state_backend = "auto"` uses the active cfgit env database type. The state +adapter stores coordination data beside cfgit history, using the configured +state and events collections/tables. + +If a patch touches `review_paths`, `apply_patch` validates the patch and routes +it to cfgit's branch/PR flow instead of mutating runtime. The result state is +`review_requested`, and the opened PR includes the agent session and intent ids. + +## Current Slice + +The current implementation includes sessions, claims, intents, conflicts, +idempotency, events, JSON Patch validation, safe patch application through cfgit +core, durable Mongo/Postgres state adapters, deterministic policies, and MCP +wrappers, plus branch/PR routing for review-required paths. The localhost cfgit +UI includes an optional agent manager view when the package is installed and +`[agent]` is enabled. diff --git a/plugins/cfg_agent/cfg_agent/__init__.py b/plugins/cfg_agent/cfg_agent/__init__.py new file mode 100644 index 0000000..7e8ca6f --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/__init__.py @@ -0,0 +1,19 @@ +"""Agent coordination primitives for cfgit.""" + +from cfg_agent.coordinator import AgentCoordinator +from cfg_agent.policy import AgentPolicyHook, StaticAgentPolicy +from cfg_agent.resources import ResourceRef, parse_resource, paths_overlap, resources_overlap +from cfg_agent.state import AgentStateAdapter, AgentStateError, InMemoryAgentStateAdapter + +__all__ = [ + "AgentCoordinator", + "AgentPolicyHook", + "AgentStateAdapter", + "AgentStateError", + "InMemoryAgentStateAdapter", + "ResourceRef", + "StaticAgentPolicy", + "parse_resource", + "paths_overlap", + "resources_overlap", +] diff --git a/plugins/cfg_agent/cfg_agent/actions.py b/plugins/cfg_agent/cfg_agent/actions.py new file mode 100644 index 0000000..d40be9e --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/actions.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from typing import Any + +from cfg_agent.coordinator import AgentCoordinator +from cfg_agent.state import AgentStateError, InMemoryAgentStateAdapter + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_CONFLICT = 6 + + +def envelope(data: Any, *, code: int = EXIT_OK, message: str = "") -> dict[str, Any]: + return {"status": "ok" if code == EXIT_OK else "error", "code": code, "message": message, "data": data} + + +def error_envelope(exc: AgentStateError) -> dict[str, Any]: + code = EXIT_CONFLICT if exc.code.endswith("conflict") or exc.code == "lease_conflict" else EXIT_ERROR + return envelope({"error": exc.code, **exc.details}, code=code, message=exc.message) + + +class AgentActions: + def __init__(self, coordinator: AgentCoordinator | None = None) -> None: + self.coordinator = coordinator or AgentCoordinator(InMemoryAgentStateAdapter()) + + def start_session(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.start_session, + task=str(payload.get("task") or "agent task"), + agent_id=str(payload.get("agent_id") or "agent"), + agent_kind=str(payload.get("agent_kind") or "custom"), + actor=payload.get("actor"), + tool_client=str(payload.get("tool_client") or "mcp"), + metadata=payload.get("metadata") or {}, + ) + + def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call(self.coordinator.heartbeat, str(payload["session_id"])) + + def end_session(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.end_session, + str(payload["session_id"]), + status=str(payload.get("status") or "completed"), + summary=payload.get("summary"), + ) + + def claim(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.claim, + session_id=str(payload["session_id"]), + resource=str(payload["resource"]), + ttl_seconds=int(payload["ttl_seconds"]) if payload.get("ttl_seconds") else None, + reason=str(payload.get("reason") or ""), + ) + + def release(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.release, + session_id=str(payload["session_id"]), + lease_id=str(payload["lease_id"]), + ) + + def open_intent(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.open_intent, + session_id=str(payload["session_id"]), + resources=list(payload.get("resources") or []), + summary=str(payload.get("summary") or ""), + planned_paths=list(payload.get("planned_paths") or []), + risk_level=str(payload.get("risk_level") or "medium"), + expected_base=payload.get("expected_base") or {}, + idempotency_key=payload.get("idempotency_key"), + ) + + def validate_patch(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.validate_patch, + engine=payload["engine"], + session_id=str(payload["session_id"]), + record=str(payload["record"]), + patch=list(payload.get("patch") or []), + intent_id=str(payload["intent_id"]), + base=payload.get("base"), + allow_live_drift=bool(payload.get("allow_live_drift", False)), + ) + + def apply_patch(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.apply_patch, + engine=payload["engine"], + session_id=str(payload["session_id"]), + record=str(payload["record"]), + patch=list(payload.get("patch") or []), + intent_id=str(payload["intent_id"]), + message=str(payload["message"]), + base=payload.get("base"), + idempotency_key=payload.get("idempotency_key"), + allow_live_drift=bool(payload.get("allow_live_drift", False)), + ) + + def close_intent(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.close_intent, + session_id=str(payload["session_id"]), + intent_id=str(payload["intent_id"]), + status=str(payload["status"]), + ) + + def status(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call(self.coordinator.status, payload.get("session_id")) + + def conflicts(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call(self.coordinator.conflicts, payload.get("status")) + + def watch(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._call( + self.coordinator.watch, + since_event_id=payload.get("since_event_id"), + limit=int(payload.get("limit") or 100), + ) + + @staticmethod + def _call(fn, *args, **kwargs) -> dict[str, Any]: + try: + return envelope(fn(*args, **kwargs)) + except AgentStateError as exc: + return error_envelope(exc) + except (KeyError, TypeError, ValueError) as exc: + return envelope({"error": "bad_request"}, code=EXIT_ERROR, message=str(exc)) diff --git a/plugins/cfg_agent/cfg_agent/adapters/__init__.py b/plugins/cfg_agent/cfg_agent/adapters/__init__.py new file mode 100644 index 0000000..192a8ba --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/adapters/__init__.py @@ -0,0 +1,5 @@ +"""Durable cfgit-agent state adapters.""" + +from cfg_agent.state import AgentStateAdapter + +__all__ = ["AgentStateAdapter"] diff --git a/plugins/cfg_agent/cfg_agent/adapters/mongo.py b/plugins/cfg_agent/cfg_agent/adapters/mongo.py new file mode 100644 index 0000000..1b74bdc --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/adapters/mongo.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timedelta +from typing import Any + +from cfg.core.config import ProjectConfig +from cfg_agent.resources import parse_resource, resources_overlap +from cfg_agent.state import AgentStateError, IdempotencyResult, _iso, utcnow + +try: # pragma: no cover - exercised when cfgit[mongo] is installed + from pymongo import ASCENDING, MongoClient +except ModuleNotFoundError as exc: # pragma: no cover + raise ModuleNotFoundError("install cfgit[mongo] to use MongoAgentStateAdapter") from exc + + +class MongoAgentStateAdapter: + """Mongo-backed coordination state for multi-process agent runtimes. + + State is stored beside cfgit history, not in cfgit core. Lease acquisition + uses a small env-scoped lock document so overlapping path claims cannot race. + """ + + def __init__( + self, + *, + project: ProjectConfig, + env_name: str, + state_collection: str = "cfgit_agent_state", + events_collection: str = "cfgit_agent_events", + ) -> None: + env = project.envs[env_name] + if not env.uri and not env.history_uri: + raise ValueError(f"missing Mongo URI for env {env_name}") + self.env_name = env_name + self.history_uri = env.history_uri or env.uri + self.history_db_name = env.history_db or env.db + self.client = MongoClient(self.history_uri) + self.db = self.client[self.history_db_name] + self.state = self.db[state_collection] + self.events = self.db[events_collection] + + def init_agent_state(self) -> None: + self.state.create_index([("env", ASCENDING), ("kind", ASCENDING), ("id", ASCENDING)], unique=True) + self.state.create_index([("env", ASCENDING), ("kind", ASCENDING), ("status", ASCENDING)]) + self.state.create_index([("env", ASCENDING), ("kind", ASCENDING), ("resource", ASCENDING)]) + self.events.create_index([("env", ASCENDING), ("event_id", ASCENDING)], unique=True) + self.events.create_index([("env", ASCENDING), ("recorded_at", ASCENDING)]) + + def create_session(self, session: dict[str, Any]) -> dict[str, Any]: + return self._insert_state("session", session["session_id"], session) + + def heartbeat_session(self, session_id: str, now: datetime) -> dict[str, Any]: + session = self._require("session", session_id, "session_not_found") + session["heartbeat_at"] = _iso(now) + return self._put_state("session", session_id, session) + + def close_session(self, session_id: str, status: str, now: datetime) -> dict[str, Any]: + session = self._require("session", session_id, "session_not_found") + session["status"] = status + session["ended_at"] = _iso(now) + session["heartbeat_at"] = _iso(now) + return self._put_state("session", session_id, session) + + def get_session(self, session_id: str) -> dict[str, Any] | None: + return self._get_state("session", session_id) + + def list_sessions(self, status: str | None = None) -> list[dict[str, Any]]: + return self._list_state("session", status=status, sort_field="started_at") + + def acquire_lease(self, lease: dict[str, Any], *, now: datetime) -> dict[str, Any]: + with self.client.start_session() as session: + with session.start_transaction(): + self._touch_lock("leases", now, session=session) + self._expire_leases(now, session=session) + resource = parse_resource(lease["resource"]) + conflicts = [] + for existing in self._list_state("lease", status="active", session=session): + if existing.get("session_id") == lease["session_id"]: + continue + if resources_overlap(resource, existing["resource"]): + conflicts.append(existing) + if conflicts: + raise AgentStateError( + "lease_conflict", + "another active lease overlaps this resource", + {"leases": conflicts}, + ) + return self._insert_state("lease", lease["lease_id"], lease, session=session) + + def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict[str, Any]: + lease = self._require("lease", lease_id, "lease_not_found") + if lease.get("status") != "active": + raise AgentStateError("lease_not_active", "lease is not active", {"lease_id": lease_id}) + lease["expires_at"] = _iso(now + timedelta(seconds=ttl_seconds)) + return self._put_state("lease", lease_id, lease) + + def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: + lease = self._require("lease", lease_id, "lease_not_found") + if lease.get("status") == "active": + lease["status"] = "released" + lease["released_at"] = _iso(now) + return self._put_state("lease", lease_id, lease) + + def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: + self._expire_leases(now or utcnow()) + return self._list_state("lease", status="active" if active_only else None, sort_field="created_at") + + def open_intent(self, intent: dict[str, Any]) -> dict[str, Any]: + return self._insert_state("intent", intent["intent_id"], intent) + + def close_intent(self, intent_id: str, status: str, now: datetime) -> dict[str, Any]: + intent = self._require("intent", intent_id, "intent_not_found") + intent["status"] = status + intent["closed_at"] = _iso(now) + return self._put_state("intent", intent_id, intent) + + def get_intent(self, intent_id: str) -> dict[str, Any] | None: + return self._get_state("intent", intent_id) + + def list_intents(self, status: str | None = None) -> list[dict[str, Any]]: + return self._list_state("intent", status=status, sort_field="created_at") + + def remember_idempotency(self, key: str, payload_hash: str, result: dict[str, Any], now: datetime) -> IdempotencyResult: + existing = self._get_state("idempotency", key) + if existing: + if existing.get("payload_hash") != payload_hash: + raise AgentStateError( + "idempotency_conflict", + "idempotency key was already used with a different payload", + {"key": key}, + ) + return IdempotencyResult(replay=True, result=deepcopy(existing.get("result"))) + self._insert_state( + "idempotency", + key, + { + "kind": "idempotency", + "idempotency_key": key, + "payload_hash": payload_hash, + "result": deepcopy(result), + "created_at": _iso(now), + }, + ) + return IdempotencyResult(replay=False, result=None) + + def get_idempotency(self, key: str) -> dict[str, Any] | None: + return self._get_state("idempotency", key) + + def open_conflict(self, conflict: dict[str, Any]) -> dict[str, Any]: + return self._insert_state("conflict", conflict["conflict_id"], conflict) + + def resolve_conflict(self, conflict_id: str, resolution: str, now: datetime) -> dict[str, Any]: + conflict = self._require("conflict", conflict_id, "conflict_not_found") + conflict["status"] = "resolved" + conflict["resolution"] = resolution + conflict["resolved_at"] = _iso(now) + return self._put_state("conflict", conflict_id, conflict) + + def list_conflicts(self, status: str | None = None) -> list[dict[str, Any]]: + return self._list_state("conflict", status=status, sort_field="created_at") + + def append_event(self, event: dict[str, Any]) -> None: + doc = {**deepcopy(event), "env": self.env_name} + self.events.insert_one(doc) + + def list_events(self, *, since_event_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + cursor = self.events.find({"env": self.env_name}).sort([("recorded_at", ASCENDING), ("event_id", ASCENDING)]) + rows = [_strip(row) for row in cursor] + if since_event_id: + for index, event in enumerate(rows): + if event.get("event_id") == since_event_id: + rows = rows[index + 1 :] + break + return deepcopy(rows[-limit:]) + + def _insert_state( + self, + kind: str, + item_id: str, + doc: dict[str, Any], + *, + session: Any | None = None, + ) -> dict[str, Any]: + item = {**deepcopy(doc), "env": self.env_name, "kind": kind, "id": item_id} + self.state.insert_one(item, session=session) + return _strip(item) + + def _put_state( + self, + kind: str, + item_id: str, + doc: dict[str, Any], + *, + session: Any | None = None, + ) -> dict[str, Any]: + item = {**deepcopy(doc), "env": self.env_name, "kind": kind, "id": item_id} + self.state.replace_one( + {"env": self.env_name, "kind": kind, "id": item_id}, + item, + upsert=True, + session=session, + ) + return _strip(item) + + def _get_state(self, kind: str, item_id: str, *, session: Any | None = None) -> dict[str, Any] | None: + row = self.state.find_one({"env": self.env_name, "kind": kind, "id": item_id}, session=session) + return _strip(row) if row else None + + def _require(self, kind: str, item_id: str, code: str) -> dict[str, Any]: + item = self._get_state(kind, item_id) + if item is None: + raise AgentStateError(code, f"{item_id} was not found", {"id": item_id}) + return item + + def _list_state( + self, + kind: str, + *, + status: str | None = None, + sort_field: str = "created_at", + session: Any | None = None, + ) -> list[dict[str, Any]]: + query: dict[str, Any] = {"env": self.env_name, "kind": kind} + if status is not None: + query["status"] = status + cursor = self.state.find(query, session=session).sort([(sort_field, -1)]) + return [_strip(row) for row in cursor] + + def _expire_leases(self, now: datetime, *, session: Any | None = None) -> None: + self.state.update_many( + { + "env": self.env_name, + "kind": "lease", + "status": "active", + "expires_at": {"$lte": _iso(now)}, + }, + {"$set": {"status": "expired"}}, + session=session, + ) + + def _touch_lock(self, lock_id: str, now: datetime, *, session: Any | None = None) -> None: + self.state.update_one( + {"env": self.env_name, "kind": "lock", "id": lock_id}, + { + "$set": {"updated_at": _iso(now)}, + "$setOnInsert": {"env": self.env_name, "kind": "lock", "id": lock_id}, + }, + upsert=True, + session=session, + ) + + +def _strip(row: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in row.items() if key not in {"_id", "env", "id"}} diff --git a/plugins/cfg_agent/cfg_agent/adapters/postgres.py b/plugins/cfg_agent/cfg_agent/adapters/postgres.py new file mode 100644 index 0000000..b572219 --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/adapters/postgres.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timezone, timedelta +import re +from typing import Any + +from cfg.core.config import ProjectConfig +from cfg_agent.resources import parse_resource, resources_overlap +from cfg_agent.state import AgentStateError, IdempotencyResult, _iso, utcnow + +try: # pragma: no cover - exercised when cfgit[postgres] is installed + import psycopg + from psycopg.rows import dict_row + from psycopg.types.json import Jsonb +except ModuleNotFoundError as exc: # pragma: no cover + raise ModuleNotFoundError("install cfgit[postgres] to use PostgresAgentStateAdapter") from exc + + +_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class PostgresAgentStateAdapter: + """Postgres-backed coordination state for multi-process agent runtimes.""" + + def __init__( + self, + *, + project: ProjectConfig, + env_name: str, + state_collection: str = "cfgit_agent_state", + events_collection: str = "cfgit_agent_events", + ) -> None: + env = project.envs[env_name] + if not env.uri and not env.history_uri: + raise ValueError(f"missing Postgres URI for env {env_name}") + self.env_name = env_name + self.conn = psycopg.connect(env.history_uri or env.uri, autocommit=True, row_factory=dict_row) + self.state_table_name = state_collection + self.events_table_name = events_collection + self.state_table = _ident(state_collection) + self.events_table = _ident(events_collection) + + def init_agent_state(self) -> None: + with self.conn.cursor() as cur: + cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.state_table} ( + env text NOT NULL, + kind text NOT NULL, + id text NOT NULL, + status text, + resource text, + updated_at timestamptz NOT NULL, + doc jsonb NOT NULL, + PRIMARY KEY (env, kind, id) + ) + """ + ) + cur.execute( + f"CREATE INDEX IF NOT EXISTS {_ident(self.state_table_name + '_status_idx')} " + f"ON {self.state_table} (env, kind, status)" + ) + cur.execute( + f"CREATE INDEX IF NOT EXISTS {_ident(self.state_table_name + '_resource_idx')} " + f"ON {self.state_table} (env, kind, resource)" + ) + cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.events_table} ( + env text NOT NULL, + event_id text NOT NULL, + event text NOT NULL, + session_id text, + actor text, + resource text, + recorded_at timestamptz NOT NULL, + doc jsonb NOT NULL, + PRIMARY KEY (env, event_id) + ) + """ + ) + cur.execute( + f"CREATE INDEX IF NOT EXISTS {_ident(self.events_table_name + '_recorded_idx')} " + f"ON {self.events_table} (env, recorded_at)" + ) + + def create_session(self, session: dict[str, Any]) -> dict[str, Any]: + return self._insert_state("session", session["session_id"], session) + + def heartbeat_session(self, session_id: str, now: datetime) -> dict[str, Any]: + session = self._require("session", session_id, "session_not_found") + session["heartbeat_at"] = _iso(now) + return self._put_state("session", session_id, session) + + def close_session(self, session_id: str, status: str, now: datetime) -> dict[str, Any]: + session = self._require("session", session_id, "session_not_found") + session["status"] = status + session["ended_at"] = _iso(now) + session["heartbeat_at"] = _iso(now) + return self._put_state("session", session_id, session) + + def get_session(self, session_id: str) -> dict[str, Any] | None: + return self._get_state("session", session_id) + + def list_sessions(self, status: str | None = None) -> list[dict[str, Any]]: + return self._list_state("session", status=status, sort_field="started_at") + + def acquire_lease(self, lease: dict[str, Any], *, now: datetime) -> dict[str, Any]: + with self.conn.transaction(): + self._touch_lock("leases", now) + self._expire_leases(now) + resource = parse_resource(lease["resource"]) + conflicts = [] + for existing in self._list_state("lease", status="active"): + if existing.get("session_id") == lease["session_id"]: + continue + if resources_overlap(resource, existing["resource"]): + conflicts.append(existing) + if conflicts: + raise AgentStateError( + "lease_conflict", + "another active lease overlaps this resource", + {"leases": conflicts}, + ) + return self._insert_state("lease", lease["lease_id"], lease) + + def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict[str, Any]: + lease = self._require("lease", lease_id, "lease_not_found") + if lease.get("status") != "active": + raise AgentStateError("lease_not_active", "lease is not active", {"lease_id": lease_id}) + lease["expires_at"] = _iso(now + timedelta(seconds=ttl_seconds)) + return self._put_state("lease", lease_id, lease) + + def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: + lease = self._require("lease", lease_id, "lease_not_found") + if lease.get("status") == "active": + lease["status"] = "released" + lease["released_at"] = _iso(now) + return self._put_state("lease", lease_id, lease) + + def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: + self._expire_leases(now or utcnow()) + return self._list_state("lease", status="active" if active_only else None, sort_field="created_at") + + def open_intent(self, intent: dict[str, Any]) -> dict[str, Any]: + return self._insert_state("intent", intent["intent_id"], intent) + + def close_intent(self, intent_id: str, status: str, now: datetime) -> dict[str, Any]: + intent = self._require("intent", intent_id, "intent_not_found") + intent["status"] = status + intent["closed_at"] = _iso(now) + return self._put_state("intent", intent_id, intent) + + def get_intent(self, intent_id: str) -> dict[str, Any] | None: + return self._get_state("intent", intent_id) + + def list_intents(self, status: str | None = None) -> list[dict[str, Any]]: + return self._list_state("intent", status=status, sort_field="created_at") + + def remember_idempotency(self, key: str, payload_hash: str, result: dict[str, Any], now: datetime) -> IdempotencyResult: + existing = self._get_state("idempotency", key) + if existing: + if existing.get("payload_hash") != payload_hash: + raise AgentStateError( + "idempotency_conflict", + "idempotency key was already used with a different payload", + {"key": key}, + ) + return IdempotencyResult(replay=True, result=deepcopy(existing.get("result"))) + self._insert_state( + "idempotency", + key, + { + "kind": "idempotency", + "idempotency_key": key, + "payload_hash": payload_hash, + "result": deepcopy(result), + "created_at": _iso(now), + }, + ) + return IdempotencyResult(replay=False, result=None) + + def get_idempotency(self, key: str) -> dict[str, Any] | None: + return self._get_state("idempotency", key) + + def open_conflict(self, conflict: dict[str, Any]) -> dict[str, Any]: + return self._insert_state("conflict", conflict["conflict_id"], conflict) + + def resolve_conflict(self, conflict_id: str, resolution: str, now: datetime) -> dict[str, Any]: + conflict = self._require("conflict", conflict_id, "conflict_not_found") + conflict["status"] = "resolved" + conflict["resolution"] = resolution + conflict["resolved_at"] = _iso(now) + return self._put_state("conflict", conflict_id, conflict) + + def list_conflicts(self, status: str | None = None) -> list[dict[str, Any]]: + return self._list_state("conflict", status=status, sort_field="created_at") + + def append_event(self, event: dict[str, Any]) -> None: + doc = deepcopy(event) + recorded_at = _parse_recorded_at(doc["recorded_at"]) + with self.conn.cursor() as cur: + cur.execute( + f""" + INSERT INTO {self.events_table} + (env, event_id, event, session_id, actor, resource, recorded_at, doc) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + [ + self.env_name, + doc["event_id"], + doc["event"], + doc.get("session_id"), + doc.get("actor"), + doc.get("resource"), + recorded_at, + Jsonb(_jsonable(doc)), + ], + ) + + def list_events(self, *, since_event_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + with self.conn.cursor() as cur: + cur.execute( + f"SELECT doc FROM {self.events_table} WHERE env = %s ORDER BY recorded_at ASC, event_id ASC", + [self.env_name], + ) + rows = [dict(row["doc"]) for row in cur.fetchall()] + if since_event_id: + for index, event in enumerate(rows): + if event.get("event_id") == since_event_id: + rows = rows[index + 1 :] + break + return deepcopy(rows[-limit:]) + + def _insert_state(self, kind: str, item_id: str, doc: dict[str, Any]) -> dict[str, Any]: + if self._get_state(kind, item_id) is not None: + raise AgentStateError("state_conflict", f"{kind} already exists", {"id": item_id}) + return self._put_state(kind, item_id, doc) + + def _put_state(self, kind: str, item_id: str, doc: dict[str, Any]) -> dict[str, Any]: + item = deepcopy(doc) + status = item.get("status") + resource = item.get("resource") + with self.conn.cursor() as cur: + cur.execute( + f""" + INSERT INTO {self.state_table} (env, kind, id, status, resource, updated_at, doc) + VALUES (%s, %s, %s, %s, %s, now(), %s) + ON CONFLICT (env, kind, id) + DO UPDATE SET + status = EXCLUDED.status, + resource = EXCLUDED.resource, + updated_at = EXCLUDED.updated_at, + doc = EXCLUDED.doc + """, + [self.env_name, kind, item_id, status, resource, Jsonb(_jsonable(item))], + ) + return item + + def _get_state(self, kind: str, item_id: str) -> dict[str, Any] | None: + with self.conn.cursor() as cur: + cur.execute( + f"SELECT doc FROM {self.state_table} WHERE env = %s AND kind = %s AND id = %s", + [self.env_name, kind, item_id], + ) + row = cur.fetchone() + return dict(row["doc"]) if row else None + + def _require(self, kind: str, item_id: str, code: str) -> dict[str, Any]: + item = self._get_state(kind, item_id) + if item is None: + raise AgentStateError(code, f"{item_id} was not found", {"id": item_id}) + return item + + def _list_state(self, kind: str, *, status: str | None = None, sort_field: str = "created_at") -> list[dict[str, Any]]: + clauses = ["env = %s", "kind = %s"] + params: list[Any] = [self.env_name, kind] + if status is not None: + clauses.append("status = %s") + params.append(status) + with self.conn.cursor() as cur: + cur.execute( + f"SELECT doc FROM {self.state_table} WHERE {' AND '.join(clauses)} ORDER BY doc ->> %s DESC", + [*params, sort_field], + ) + return [dict(row["doc"]) for row in cur.fetchall()] + + def _expire_leases(self, now: datetime) -> None: + now_iso = _iso(now) + for lease in self._list_state("lease", status="active"): + if str(lease.get("expires_at") or "") <= now_iso: + lease["status"] = "expired" + self._put_state("lease", lease["lease_id"], lease) + + def _touch_lock(self, lock_id: str, now: datetime) -> None: + self._put_state("lock", lock_id, {"kind": "lock", "id": lock_id, "updated_at": _iso(now)}) + with self.conn.cursor() as cur: + cur.execute( + f"SELECT doc FROM {self.state_table} WHERE env = %s AND kind = 'lock' AND id = %s FOR UPDATE", + [self.env_name, lock_id], + ) + cur.fetchone() + + +def _ident(value: str) -> str: + if not _IDENT_RE.match(value): + raise ValueError(f"unsafe SQL identifier: {value}") + return f'"{value}"' + + +def _jsonable(value: Any) -> Any: + if isinstance(value, dict): + return {str(k): _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + if isinstance(value, datetime): + dt = value + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + return value + + +def _parse_recorded_at(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) diff --git a/plugins/cfg_agent/cfg_agent/config.py b/plugins/cfg_agent/cfg_agent/config.py new file mode 100644 index 0000000..a9b552d --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/config.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import Any + +from cfg.core.config import ProjectConfig, load_config +from cfg_agent.coordinator import AgentCoordinator +from cfg_agent.policy import AgentRolePolicy, StaticAgentPolicy +from cfg_agent.state import AgentStateAdapter, InMemoryAgentStateAdapter + +try: # pragma: no cover - py311+ path is normal + import tomllib +except ModuleNotFoundError: # pragma: no cover + import tomli as tomllib # type: ignore[no-redef] + + +@dataclass(frozen=True) +class AgentPluginConfig: + enabled: bool = False + state_backend: str = "memory" + state_collection: str = "cfgit_agent_state" + events_collection: str = "cfgit_agent_events" + default_lease_ttl_seconds: int = 900 + deny_paths: tuple[str, ...] = () + review_paths: tuple[str, ...] = () + require_claims: bool = True + roles: dict[str, AgentRolePolicy] = field(default_factory=dict) + + +def load_agent_config(path: str | Path | None = None) -> AgentPluginConfig: + cfg_path = _resolve_config_path(path) + if cfg_path is None: + return AgentPluginConfig() + with cfg_path.open("rb") as f: + raw = tomllib.load(f) + agent_raw = dict(raw.get("agent") or {}) + policies_raw = dict(agent_raw.get("policies") or {}) + roles = { + role.name: role + for role in (_load_role(item) for item in agent_raw.get("role", ())) + } + return AgentPluginConfig( + enabled=bool(agent_raw.get("enabled", False)), + state_backend=str(agent_raw.get("state_backend", agent_raw.get("backend", "memory"))), + state_collection=str(agent_raw.get("state_collection", "cfgit_agent_state")), + events_collection=str(agent_raw.get("events_collection", "cfgit_agent_events")), + default_lease_ttl_seconds=int(agent_raw.get("default_lease_ttl_seconds", 900)), + deny_paths=tuple(str(item) for item in policies_raw.get("deny_paths", ())), + review_paths=tuple( + str(item) + for item in policies_raw.get( + "review_paths", + policies_raw.get("require_human_review_for", ()), + ) + ), + require_claims=bool(policies_raw.get("require_claims", True)), + roles=roles, + ) + + +def make_agent_coordinator( + *, + config_file: str | Path | None = None, + env: str = "dev", + project: ProjectConfig | None = None, +) -> AgentCoordinator: + if project is None: + project = load_config(config_file) + agent_cfg = load_agent_config(config_file or project.path) + adapter = make_agent_state_adapter(project=project, env=env, agent_cfg=agent_cfg) + adapter.init_agent_state() + policy = StaticAgentPolicy( + roles=agent_cfg.roles, + deny_paths=agent_cfg.deny_paths, + review_paths=agent_cfg.review_paths, + require_claims=agent_cfg.require_claims, + ) + return AgentCoordinator( + adapter=adapter, + policy=policy, + default_lease_ttl_seconds=agent_cfg.default_lease_ttl_seconds, + ) + + +@lru_cache(maxsize=32) +def cached_agent_coordinator(config_file: str | None, env: str) -> AgentCoordinator: + return make_agent_coordinator(config_file=config_file, env=env) + + +def make_agent_state_adapter( + *, + project: ProjectConfig, + env: str, + agent_cfg: AgentPluginConfig, +) -> AgentStateAdapter: + backend = agent_cfg.state_backend.lower() + if not agent_cfg.enabled: + backend = "memory" + if backend == "auto": + backend = project.envs[env].database.lower() + if backend == "memory": + return InMemoryAgentStateAdapter() + if backend == "mongo": + from cfg_agent.adapters.mongo import MongoAgentStateAdapter + + return MongoAgentStateAdapter( + project=project, + env_name=env, + state_collection=agent_cfg.state_collection, + events_collection=agent_cfg.events_collection, + ) + if backend == "postgres": + from cfg_agent.adapters.postgres import PostgresAgentStateAdapter + + return PostgresAgentStateAdapter( + project=project, + env_name=env, + state_collection=agent_cfg.state_collection, + events_collection=agent_cfg.events_collection, + ) + raise ValueError("agent.state_backend must be memory, auto, mongo, or postgres") + + +def _load_role(data: dict[str, Any]) -> AgentRolePolicy: + name = str(data.get("name") or "").strip() + if not name: + raise ValueError("[[agent.role]] entries require name") + return AgentRolePolicy( + name=name, + can_claim=tuple(str(item) for item in data.get("can_claim", ())), + deny_paths=tuple(str(item) for item in data.get("deny_paths", ())), + review_paths=tuple(str(item) for item in data.get("review_paths", data.get("require_human_review_for", ()))), + ) + + +def _resolve_config_path(path: str | Path | None) -> Path | None: + if path is not None: + return Path(path).expanduser().resolve() + local = Path(".cfg.toml") + if local.exists(): + return local.resolve() + example = Path("examples/.cfg.toml") + if example.exists(): + return example.resolve() + return None diff --git a/plugins/cfg_agent/cfg_agent/coordinator.py b/plugins/cfg_agent/cfg_agent/coordinator.py new file mode 100644 index 0000000..c326a36 --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/coordinator.py @@ -0,0 +1,631 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import timedelta +import re +from typing import Any + +from cfg.core.diff import diff_values +from cfg.core.engine import RecordRef +from cfg_agent.patches import apply_json_patch, patch_paths +from cfg_agent.resources import parse_resource, paths_overlap, resources_overlap +from cfg_agent.policy import AgentPolicyHook, StaticAgentPolicy +from cfg_agent.state import AgentStateAdapter, AgentStateError, InMemoryAgentStateAdapter, new_id, utcnow + + +VALID_SESSION_STATUSES = {"running", "blocked", "completed", "failed", "abandoned"} +VALID_INTENT_STATUSES = {"open", "committed", "review_requested", "superseded", "rejected", "abandoned"} + + +class AgentCoordinator: + def __init__( + self, + adapter: AgentStateAdapter | None = None, + *, + policy: AgentPolicyHook | None = None, + default_lease_ttl_seconds: int = 900, + ) -> None: + self.adapter = adapter or InMemoryAgentStateAdapter() + self.policy = policy or StaticAgentPolicy() + self.default_lease_ttl_seconds = default_lease_ttl_seconds + + def start_session( + self, + *, + task: str, + agent_id: str = "agent", + agent_kind: str = "custom", + actor: str | None = None, + tool_client: str = "mcp", + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + now = utcnow() + session = { + "kind": "session", + "session_id": new_id("ses"), + "agent_id": agent_id, + "agent_kind": agent_kind, + "task": task, + "actor": actor or agent_id, + "status": "running", + "started_at": _iso(now), + "heartbeat_at": _iso(now), + "ended_at": None, + "tool_client": tool_client, + "metadata": metadata or {}, + } + created = self.adapter.create_session(session) + self._event("session.started", session_id=created["session_id"], actor=created["actor"], details=created) + return created + + def heartbeat(self, session_id: str) -> dict[str, Any]: + session = self.adapter.heartbeat_session(session_id, utcnow()) + self._event("session.heartbeat", session_id=session_id, actor=session.get("actor"), details={}) + return session + + def end_session(self, session_id: str, *, status: str = "completed", summary: str | None = None) -> dict[str, Any]: + if status not in VALID_SESSION_STATUSES: + raise AgentStateError("bad_session_status", "invalid session status", {"status": status}) + session = self.adapter.close_session(session_id, status, utcnow()) + self._event( + "session.ended", + session_id=session_id, + actor=session.get("actor"), + details={"status": status, "summary": summary}, + ) + return session + + def claim( + self, + *, + session_id: str, + resource: str, + ttl_seconds: int | None = None, + reason: str = "", + ) -> dict[str, Any]: + session = self._require_running_session(session_id) + parsed = parse_resource(resource) + self.policy.check_claim(session=session, resource=parsed.format()) + now = utcnow() + lease = { + "kind": "lease", + "lease_id": new_id("lea"), + "session_id": session_id, + "resource": parsed.format(), + "collection": parsed.collection, + "record_id": parsed.record_id, + "path": parsed.path, + "scope": parsed.scope, + "status": "active", + "reason": reason, + "created_at": _iso(now), + "expires_at": _iso(now + timedelta(seconds=ttl_seconds or self.default_lease_ttl_seconds)), + "released_at": None, + } + try: + created = self.adapter.acquire_lease(lease, now=now) + except AgentStateError as exc: + if exc.code != "lease_conflict": + raise + conflict = self._open_conflict( + session_id=session_id, + resource=parsed.format(), + conflict_type="lease_conflict", + message=exc.message, + details=exc.details, + ) + raise AgentStateError(exc.code, exc.message, {"conflict": conflict, **exc.details}) from exc + self._event( + "lease.acquired", + session_id=session_id, + actor=session.get("actor"), + resource=created["resource"], + details=created, + ) + return created + + def release(self, *, session_id: str, lease_id: str) -> dict[str, Any]: + session = self._require_running_session(session_id) + lease = self.adapter.release_lease(lease_id, now=utcnow()) + self._event( + "lease.released", + session_id=session_id, + actor=session.get("actor"), + resource=lease.get("resource"), + details=lease, + ) + return lease + + def open_intent( + self, + *, + session_id: str, + resources: list[str], + summary: str, + planned_paths: list[str], + risk_level: str = "medium", + expected_base: dict[str, Any] | None = None, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + session = self._require_running_session(session_id) + if not resources: + raise AgentStateError("bad_intent", "intent requires at least one resource") + normalized_resources = [parse_resource(resource).format() for resource in resources] + now = utcnow() + intent = { + "kind": "intent", + "intent_id": new_id("int"), + "session_id": session_id, + "resources": normalized_resources, + "summary": summary, + "planned_paths": planned_paths, + "risk_level": risk_level, + "expected_base": expected_base or {}, + "idempotency_key": idempotency_key, + "status": "open", + "created_at": _iso(now), + "closed_at": None, + } + created = self.adapter.open_intent(intent) + self._event("intent.opened", session_id=session_id, actor=session.get("actor"), details=created) + return created + + def close_intent(self, *, session_id: str, intent_id: str, status: str) -> dict[str, Any]: + session = self._require_running_session(session_id) + if status not in VALID_INTENT_STATUSES: + raise AgentStateError("bad_intent_status", "invalid intent status", {"status": status}) + intent = self.adapter.close_intent(intent_id, status, utcnow()) + self._event("intent.closed", session_id=session_id, actor=session.get("actor"), details=intent) + return intent + + def remember_idempotency( + self, + *, + key: str, + payload: dict[str, Any], + result: dict[str, Any], + ) -> dict[str, Any]: + payload_hash = _payload_hash(payload) + remembered = self.adapter.remember_idempotency(key, payload_hash, result, utcnow()) + if remembered.replay: + return {"replay": True, "result": remembered.result} + return {"replay": False, "result": result} + + def validate_patch( + self, + *, + engine: Any, + session_id: str, + record: str, + patch: list[dict[str, Any]], + intent_id: str, + base: dict[str, Any] | None = None, + allow_live_drift: bool = False, + ) -> dict[str, Any]: + session = self._require_running_session(session_id) + record_ref = _parse_record(record) + record_resource = f"{record_ref.collection}:{record_ref.record_id}" + paths = patch_paths(patch) + intent = self.adapter.get_intent(intent_id) + if intent is None or intent.get("session_id") != session_id or intent.get("status") != "open": + conflict = self._open_conflict( + session_id=session_id, + resource=record_resource, + conflict_type="intent_required", + message="open intent is required for patch validation", + details={"intent_id": intent_id}, + ) + raise AgentStateError("intent_required", "open intent is required", {"conflict": conflict}) + if not any(resources_overlap(resource, record_resource) for resource in intent.get("resources", [])): + conflict = self._open_conflict( + session_id=session_id, + resource=record_resource, + conflict_type="intent_scope", + message="intent does not include this record", + details={"intent_id": intent_id, "resources": intent.get("resources", [])}, + ) + raise AgentStateError("intent_scope", "intent does not include this record", {"conflict": conflict}) + self._check_planned_paths(session_id, record_resource, paths, intent) + if getattr(self.policy, "require_claims", True): + self._check_claims(session_id, record_ref, paths) + self.policy.check_patch( + session=session, + record=record_resource, + patch_paths=paths, + collection=engine.config.collection(record_ref.collection), + ) + + head = engine.resolve_ref(record_ref, "=HEAD") + status = engine.status(record_ref)[0] + expected = base or (intent.get("expected_base") or {}).get(record_resource) or {} + expected_seq = expected.get("head_seq") + expected_oid = expected.get("head_oid") + if expected_seq is not None and int(expected_seq) != int(head.get("seq")): + conflict = self._open_conflict( + session_id=session_id, + resource=record_resource, + conflict_type="base_moved", + message="HEAD sequence moved since the patch base was chosen", + details={"expected_head_seq": expected_seq, "actual_head_seq": head.get("seq")}, + ) + raise AgentStateError("base_moved", "HEAD sequence moved", {"conflict": conflict}) + if expected_oid is not None and expected_oid != head.get("oid"): + conflict = self._open_conflict( + session_id=session_id, + resource=record_resource, + conflict_type="base_moved", + message="HEAD oid moved since the patch base was chosen", + details={"expected_head_oid": expected_oid, "actual_head_oid": head.get("oid")}, + ) + raise AgentStateError("base_moved", "HEAD oid moved", {"conflict": conflict}) + if status.state == "changed_outside_cfgit" and not allow_live_drift: + conflict = self._open_conflict( + session_id=session_id, + resource=record_resource, + conflict_type="live_drift", + message="live record differs from cfgit HEAD", + details={"live_oid": status.live_oid, "head_oid": status.head_oid}, + ) + raise AgentStateError("live_drift", "live record differs from cfgit HEAD", {"conflict": conflict}) + + patched = apply_json_patch(head["doc"], patch) + changes = diff_values(head["doc"], patched) + result = { + "state": "ok", + "record": record_resource, + "base": {"head_seq": head.get("seq"), "head_oid": head.get("oid")}, + "patch_paths": paths, + "changes": changes, + "patched_doc": patched, + "intent_id": intent_id, + } + self._event( + "patch.validated", + session_id=session_id, + actor=session.get("actor"), + resource=record_resource, + details={key: value for key, value in result.items() if key != "patched_doc"}, + ) + return result + + def apply_patch( + self, + *, + engine: Any, + session_id: str, + record: str, + patch: list[dict[str, Any]], + intent_id: str, + message: str, + base: dict[str, Any] | None = None, + idempotency_key: str | None = None, + allow_live_drift: bool = False, + ) -> dict[str, Any]: + session = self._require_running_session(session_id) + payload = { + "record": record, + "patch": patch, + "intent_id": intent_id, + "message": message, + "base": base, + "allow_live_drift": allow_live_drift, + } + payload_hash = _payload_hash(payload) + if idempotency_key: + existing = self.adapter.get_idempotency(idempotency_key) + if existing: + if existing.get("payload_hash") != payload_hash: + raise AgentStateError( + "idempotency_conflict", + "idempotency key was already used with a different payload", + {"key": idempotency_key}, + ) + return { + "state": "replayed", + "idempotency_key": idempotency_key, + "result": existing.get("result"), + } + + validation = self.validate_patch( + engine=engine, + session_id=session_id, + record=record, + patch=patch, + intent_id=intent_id, + base=base, + allow_live_drift=allow_live_drift, + ) + review = self.policy.review_required( + session=session, + record=validation["record"], + patch_paths=validation["patch_paths"], + ) + if review: + result = self._route_patch_to_pr( + engine=engine, + session=session, + validation=validation, + message=message, + review=review, + ) + self.adapter.close_intent(intent_id, "review_requested", utcnow()) + if idempotency_key: + self.adapter.remember_idempotency(idempotency_key, payload_hash, result, utcnow()) + return result + record_ref = _parse_record(record) + commit = engine.commit(record_ref, validation["patched_doc"], message=message) + if commit.get("state") != "committed": + conflict = self._open_conflict( + session_id=session_id, + resource=validation["record"], + conflict_type="apply_blocked", + message="cfgit core did not commit the patch", + details={"commit": commit}, + ) + raise AgentStateError("apply_blocked", "cfgit core did not commit the patch", {"conflict": conflict}) + result = { + "state": "applied", + "record": validation["record"], + "intent_id": intent_id, + "commit": commit, + "changes": validation["changes"], + } + self.adapter.close_intent(intent_id, "committed", utcnow()) + if idempotency_key: + self.adapter.remember_idempotency(idempotency_key, payload_hash, result, utcnow()) + self._event( + "patch.applied", + session_id=session_id, + actor=session.get("actor"), + resource=validation["record"], + details={key: value for key, value in result.items() if key != "changes"}, + ) + self._event( + "commit.created", + session_id=session_id, + actor=session.get("actor"), + resource=validation["record"], + details=commit, + ) + return result + + def _route_patch_to_pr( + self, + *, + engine: Any, + session: dict[str, Any], + validation: dict[str, Any], + message: str, + review: dict[str, Any], + ) -> dict[str, Any]: + if not engine.config.branches.enabled: + conflict = self._open_conflict( + session_id=session["session_id"], + resource=validation["record"], + conflict_type="review_unavailable", + message="agent policy requires review, but cfgit branches are not enabled", + details={"review": review}, + ) + raise AgentStateError( + "review_unavailable", + "agent policy requires review, but cfgit branches are not enabled", + {"conflict": conflict}, + ) + record_ref = _parse_record(validation["record"]) + branch = self._create_review_branch(engine, session=session, record=validation["record"]) + draft = engine.branch_commit( + branch, + record_ref, + validation["patched_doc"], + message=message, + ) + if draft.get("state") != "committed": + conflict = self._open_conflict( + session_id=session["session_id"], + resource=validation["record"], + conflict_type="review_branch_blocked", + message="cfgit branch draft commit was blocked", + details={"draft": draft, "review": review}, + ) + raise AgentStateError("review_branch_blocked", "cfgit branch draft commit was blocked", {"conflict": conflict}) + pr = engine.pr_create( + base=engine.config.branches.default_branch, + head=branch, + message=message, + ) + linked_pr = { + **pr, + "agent": { + "session_id": session["session_id"], + "agent_id": session.get("agent_id"), + "intent_id": validation["intent_id"], + "review": review, + }, + } + engine.adapter.put_ref(linked_pr) + result = { + "state": "review_requested", + "record": validation["record"], + "intent_id": validation["intent_id"], + "branch": branch, + "draft_commit": draft, + "pr": linked_pr, + "review": review, + "runtime_mutated": False, + } + self._event( + "patch.routed_to_pr", + session_id=session["session_id"], + actor=session.get("actor"), + resource=validation["record"], + details={ + "branch": branch, + "pr_id": linked_pr["id"], + "intent_id": validation["intent_id"], + "review": review, + }, + ) + self._event( + "pr.created", + session_id=session["session_id"], + actor=session.get("actor"), + resource=validation["record"], + details=linked_pr, + ) + return result + + def _create_review_branch(self, engine: Any, *, session: dict[str, Any], record: str) -> str: + prefix = f"agent/{_safe_branch_part(session.get('agent_id') or 'agent')}/{_safe_branch_part(record)}" + for _attempt in range(5): + branch = f"{prefix}/{new_id('rev')[-8:]}"[:128].rstrip("/.") + try: + engine.branch_create(branch, from_branch=engine.config.branches.default_branch, message=f"agent review {record}") + return branch + except ValueError as exc: + if "branch already exists" not in str(exc): + raise + raise AgentStateError("branch_conflict", "could not create a unique agent review branch", {"record": record}) + + def status(self, session_id: str | None = None) -> dict[str, Any]: + return { + "session": self.adapter.get_session(session_id) if session_id else None, + "sessions": [] if session_id else self.adapter.list_sessions(), + "leases": self.adapter.list_leases(active_only=False), + "intents": self.adapter.list_intents(), + "conflicts": self.adapter.list_conflicts(status=None), + } + + def conflicts(self, status: str | None = None) -> list[dict[str, Any]]: + return self.adapter.list_conflicts(status=status) + + def watch(self, *, since_event_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + return self.adapter.list_events(since_event_id=since_event_id, limit=limit) + + def _check_planned_paths( + self, + session_id: str, + record_resource: str, + paths: list[str], + intent: dict[str, Any], + ) -> None: + planned = list(intent.get("planned_paths") or []) + missing = [path for path in paths if not any(paths_overlap(plan, path) for plan in planned)] + if missing: + conflict = self._open_conflict( + session_id=session_id, + resource=record_resource, + conflict_type="intent_scope", + message="patch touches paths outside the declared intent", + details={"missing_paths": missing, "planned_paths": planned}, + ) + raise AgentStateError("intent_scope", "patch touches paths outside the declared intent", {"conflict": conflict}) + + def _check_claims(self, session_id: str, record_ref: RecordRef, paths: list[str]) -> None: + leases = [ + lease + for lease in self.adapter.list_leases(active_only=True) + if lease.get("session_id") == session_id + ] + missing: list[str] = [] + for path in paths: + target = f"{record_ref.collection}:{record_ref.record_id}:{path}" + if not any(resources_overlap(lease["resource"], target) for lease in leases): + missing.append(path) + if missing: + resource = f"{record_ref.collection}:{record_ref.record_id}" + conflict = self._open_conflict( + session_id=session_id, + resource=resource, + conflict_type="claim_required", + message="active lease is required for every patch path", + details={"missing_paths": missing}, + ) + raise AgentStateError("claim_required", "active lease is required for every patch path", {"conflict": conflict}) + + def _require_running_session(self, session_id: str) -> dict[str, Any]: + session = self.adapter.get_session(session_id) + if session is None: + raise AgentStateError("session_not_found", "session was not found", {"session_id": session_id}) + if session.get("status") not in {"running", "blocked"}: + raise AgentStateError( + "session_not_active", + "session is not active", + {"session_id": session_id, "status": session.get("status")}, + ) + return session + + def _open_conflict( + self, + *, + session_id: str, + resource: str, + conflict_type: str, + message: str, + details: dict[str, Any] | None = None, + ) -> dict[str, Any]: + now = utcnow() + conflict = { + "kind": "conflict", + "conflict_id": new_id("con"), + "session_id": session_id, + "resource": resource, + "type": conflict_type, + "severity": "blocking", + "sessions": [session_id], + "paths": [parse_resource(resource).path] if parse_resource(resource).path else [], + "message": message, + "resolution": None, + "status": "open", + "created_at": _iso(now), + "resolved_at": None, + "details": details or {}, + } + created = self.adapter.open_conflict(conflict) + self._event("conflict.detected", session_id=session_id, resource=resource, details=created) + return created + + def _event( + self, + event: str, + *, + session_id: str | None = None, + actor: str | None = None, + resource: str | None = None, + details: dict[str, Any] | None = None, + ) -> None: + self.adapter.append_event( + { + "event_id": new_id("evt"), + "event": event, + "session_id": session_id, + "actor": actor, + "resource": resource, + "recorded_at": _iso(utcnow()), + "details": details or {}, + } + ) + + +def _payload_hash(payload: dict[str, Any]) -> str: + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _iso(value) -> str: + return value.isoformat().replace("+00:00", "Z") + + +def _parse_record(record: str) -> RecordRef: + if ":" not in record: + raise AgentStateError("bad_record", "record must look like collection:id") + collection, record_id = record.split(":", 1) + if not collection or not record_id: + raise AgentStateError("bad_record", "record must look like collection:id") + return RecordRef(collection, record_id) + + +def _safe_branch_part(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._/-]+", "-", str(value)).strip("-/.") + cleaned = cleaned.replace("..", ".").replace("//", "/") + return cleaned[:40] or "item" diff --git a/plugins/cfg_agent/cfg_agent/mcp.py b/plugins/cfg_agent/cfg_agent/mcp.py new file mode 100644 index 0000000..ab05ce1 --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/mcp.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import json +from typing import Any + +from cfg.interfaces.actions import ActionContext, make_engine +from cfg_agent.actions import AgentActions +from cfg_agent.config import cached_agent_coordinator + +try: # pragma: no cover - exercised when cfgit[mcp] is installed + from mcp.server.fastmcp import FastMCP +except ModuleNotFoundError: # pragma: no cover + FastMCP = None # type: ignore[assignment] + + +def _mcp() -> Any: + if FastMCP is None: + raise ModuleNotFoundError("install cfgit[mcp] to run the cfgit-agent MCP server") + return FastMCP("cfgit-agent") + + +mcp = _mcp() +actions = AgentActions() + + +def _actions(config_file: str | None = None, env: str = "dev") -> AgentActions: + if config_file is None: + return actions + return AgentActions(cached_agent_coordinator(config_file, env)) + + +@mcp.tool() +def cfg_agent_start_session( + task: str, + agent_id: str = "agent", + agent_kind: str = "custom", + actor: str | None = None, + tool_client: str = "mcp", + metadata: dict[str, Any] | None = None, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).start_session( + { + "task": task, + "agent_id": agent_id, + "agent_kind": agent_kind, + "actor": actor, + "tool_client": tool_client, + "metadata": metadata or {}, + } + ) + + +@mcp.tool() +def cfg_agent_heartbeat(session_id: str, config_file: str | None = None, env: str = "dev") -> dict[str, Any]: + return _actions(config_file, env).heartbeat({"session_id": session_id}) + + +@mcp.tool() +def cfg_agent_end_session( + session_id: str, + status: str = "completed", + summary: str | None = None, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).end_session({"session_id": session_id, "status": status, "summary": summary}) + + +@mcp.tool() +def cfg_agent_claim( + session_id: str, + resource: str, + ttl_seconds: int | None = None, + reason: str = "", + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).claim( + { + "session_id": session_id, + "resource": resource, + "ttl_seconds": ttl_seconds, + "reason": reason, + } + ) + + +@mcp.tool() +def cfg_agent_release( + session_id: str, + lease_id: str, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).release({"session_id": session_id, "lease_id": lease_id}) + + +@mcp.tool() +def cfg_agent_open_intent( + session_id: str, + resources: list[str], + summary: str, + planned_paths: list[str], + risk_level: str = "medium", + expected_base: dict[str, Any] | None = None, + idempotency_key: str | None = None, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).open_intent( + { + "session_id": session_id, + "resources": resources, + "summary": summary, + "planned_paths": planned_paths, + "risk_level": risk_level, + "expected_base": expected_base or {}, + "idempotency_key": idempotency_key, + } + ) + + +@mcp.tool() +def cfg_agent_close_intent( + session_id: str, + intent_id: str, + status: str, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).close_intent({"session_id": session_id, "intent_id": intent_id, "status": status}) + + +@mcp.tool() +def cfg_agent_validate_patch( + session_id: str, + record: str, + patch: list[dict[str, Any]] | str, + intent_id: str, + base: dict[str, Any] | None = None, + allow_live_drift: bool = False, + config_file: str | None = None, + env: str = "dev", + author: str | None = None, +) -> dict[str, Any]: + engine = make_engine(ActionContext(config_file=config_file, env=env, author=author)) + parsed_patch = json.loads(patch) if isinstance(patch, str) else patch + return _actions(config_file, env).validate_patch( + { + "engine": engine, + "session_id": session_id, + "record": record, + "patch": parsed_patch, + "intent_id": intent_id, + "base": base, + "allow_live_drift": allow_live_drift, + } + ) + + +@mcp.tool() +def cfg_agent_apply_patch( + session_id: str, + record: str, + patch: list[dict[str, Any]] | str, + intent_id: str, + message: str, + base: dict[str, Any] | None = None, + idempotency_key: str | None = None, + allow_live_drift: bool = False, + config_file: str | None = None, + env: str = "dev", + author: str | None = None, +) -> dict[str, Any]: + engine = make_engine(ActionContext(config_file=config_file, env=env, author=author)) + parsed_patch = json.loads(patch) if isinstance(patch, str) else patch + return _actions(config_file, env).apply_patch( + { + "engine": engine, + "session_id": session_id, + "record": record, + "patch": parsed_patch, + "intent_id": intent_id, + "message": message, + "base": base, + "idempotency_key": idempotency_key, + "allow_live_drift": allow_live_drift, + } + ) + + +@mcp.tool() +def cfg_agent_status( + session_id: str | None = None, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).status({"session_id": session_id}) + + +@mcp.tool() +def cfg_agent_conflicts( + status: str | None = None, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).conflicts({"status": status}) + + +@mcp.tool() +def cfg_agent_watch( + since_event_id: str | None = None, + limit: int = 100, + config_file: str | None = None, + env: str = "dev", +) -> dict[str, Any]: + return _actions(config_file, env).watch({"since_event_id": since_event_id, "limit": limit}) + + +def reset_for_tests() -> None: + global actions + cached_agent_coordinator.cache_clear() + actions = AgentActions() diff --git a/plugins/cfg_agent/cfg_agent/patches.py b/plugins/cfg_agent/cfg_agent/patches.py new file mode 100644 index 0000000..4526f8a --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/patches.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +SUPPORTED_OPS = {"add", "replace", "remove"} + + +def apply_json_patch(doc: dict[str, Any], patch: list[dict[str, Any]]) -> dict[str, Any]: + result = deepcopy(doc) + for op in patch: + operation = op.get("op") + path = op.get("path") + if operation not in SUPPORTED_OPS: + raise ValueError(f"unsupported JSON Patch op: {operation}") + if not isinstance(path, str) or not path.startswith("/"): + raise ValueError("JSON Patch path must start with /") + if operation == "remove": + _remove(result, path) + elif operation == "replace": + _replace(result, path, op.get("value")) + elif operation == "add": + _add(result, path, op.get("value")) + return result + + +def patch_paths(patch: list[dict[str, Any]]) -> list[str]: + paths: list[str] = [] + for op in patch: + path = op.get("path") + if not isinstance(path, str) or not path.startswith("/"): + raise ValueError("JSON Patch path must start with /") + paths.append(path) + return paths + + +def _add(doc: Any, path: str, value: Any) -> None: + parent, key = _parent(doc, path) + if isinstance(parent, list): + if key == "-": + parent.append(value) + return + index = _list_index(parent, key, allow_end=True) + parent.insert(index, value) + return + if not isinstance(parent, dict): + raise ValueError(f"cannot add into non-container at {path}") + parent[key] = value + + +def _replace(doc: Any, path: str, value: Any) -> None: + parent, key = _parent(doc, path) + if isinstance(parent, list): + parent[_list_index(parent, key)] = value + return + if not isinstance(parent, dict) or key not in parent: + raise ValueError(f"cannot replace missing path {path}") + parent[key] = value + + +def _remove(doc: Any, path: str) -> None: + parent, key = _parent(doc, path) + if isinstance(parent, list): + parent.pop(_list_index(parent, key)) + return + if not isinstance(parent, dict) or key not in parent: + raise ValueError(f"cannot remove missing path {path}") + del parent[key] + + +def _parent(doc: Any, path: str) -> tuple[Any, str]: + parts = _parts(path) + if not parts: + raise ValueError("root-level JSON Patch operations are not supported") + current = doc + for part in parts[:-1]: + if isinstance(current, list): + current = current[_list_index(current, part)] + elif isinstance(current, dict) and part in current: + current = current[part] + else: + raise ValueError(f"path does not exist: {path}") + return current, parts[-1] + + +def _parts(path: str) -> list[str]: + return [_decode(part) for part in path.split("/")[1:]] + + +def _decode(part: str) -> str: + return part.replace("~1", "/").replace("~0", "~") + + +def _list_index(items: list[Any], raw: str, *, allow_end: bool = False) -> int: + if raw == "-" and allow_end: + return len(items) + try: + index = int(raw) + except ValueError as exc: + raise ValueError(f"invalid list index: {raw}") from exc + upper = len(items) if allow_end else len(items) - 1 + if index < 0 or index > upper: + raise ValueError(f"list index out of range: {raw}") + return index diff --git a/plugins/cfg_agent/cfg_agent/policy.py b/plugins/cfg_agent/cfg_agent/policy.py new file mode 100644 index 0000000..2d36c65 --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/policy.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import fnmatch +from typing import Any, Protocol, runtime_checkable + +from cfg.core.config import CollectionConfig +from cfg_agent.resources import paths_overlap +from cfg_agent.state import AgentStateError + + +@runtime_checkable +class AgentPolicyHook(Protocol): + def check_claim(self, *, session: dict[str, Any], resource: str) -> None: ... + def check_patch( + self, + *, + session: dict[str, Any], + record: str, + patch_paths: list[str], + collection: CollectionConfig, + ) -> None: ... + def review_required(self, *, session: dict[str, Any], record: str, patch_paths: list[str]) -> dict[str, Any] | None: ... + + +@dataclass(frozen=True) +class AgentRolePolicy: + name: str + can_claim: tuple[str, ...] = () + deny_paths: tuple[str, ...] = () + review_paths: tuple[str, ...] = () + + +@dataclass(frozen=True) +class StaticAgentPolicy: + roles: dict[str, AgentRolePolicy] = field(default_factory=dict) + deny_paths: tuple[str, ...] = () + review_paths: tuple[str, ...] = () + require_claims: bool = True + + def check_claim(self, *, session: dict[str, Any], resource: str) -> None: + role = self._role_for(session) + patterns = role.can_claim if role else () + denied = (*self.deny_paths, *(role.deny_paths if role else ())) + if denied and _matches_resource_or_path(resource, denied): + raise AgentStateError( + "policy_blocked", + "agent policy denies this resource", + {"resource": resource, "policy": "deny_paths"}, + ) + if patterns and not _matches_resource_or_path(resource, patterns): + raise AgentStateError( + "policy_blocked", + "agent is not allowed to claim this resource", + {"resource": resource, "allowed": list(patterns)}, + ) + + def check_patch( + self, + *, + session: dict[str, Any], + record: str, + patch_paths: list[str], + collection: CollectionConfig, + ) -> None: + role = self._role_for(session) + denied = (*self.deny_paths, *(role.deny_paths if role else ())) + for path in patch_paths: + full = f"{record}:{path}" + if denied and _matches_resource_or_path(full, denied): + raise AgentStateError( + "policy_blocked", + "agent policy denies this patch path", + {"record": record, "path": path, "policy": "deny_paths"}, + ) + for secret_path in _secret_json_paths(collection): + if paths_overlap(secret_path, path): + raise AgentStateError( + "policy_blocked", + "agent patch touches a configured secret field", + {"record": record, "path": path, "secret_path": secret_path}, + ) + + def review_required(self, *, session: dict[str, Any], record: str, patch_paths: list[str]) -> dict[str, Any] | None: + role = self._role_for(session) + review_patterns = (*self.review_paths, *(role.review_paths if role else ())) + for path in patch_paths: + full = f"{record}:{path}" + if review_patterns and _matches_resource_or_path(full, review_patterns): + return { + "status": "needs_review", + "code": "human_review_required", + "message": "agent policy requires branch/PR review before runtime mutation", + "record": record, + "path": path, + "patterns": list(review_patterns), + } + return None + + def _role_for(self, session: dict[str, Any]) -> AgentRolePolicy | None: + agent_id = str(session.get("agent_id") or "") + agent_kind = str(session.get("agent_kind") or "") + return self.roles.get(agent_id) or self.roles.get(agent_kind) + + +def _matches_resource_or_path(value: str, patterns: tuple[str, ...]) -> bool: + resource = value + path = _path_part(value) + for pattern in patterns: + if fnmatch.fnmatchcase(resource, pattern): + return True + if pattern.startswith("/") and path is not None and fnmatch.fnmatchcase(path, pattern): + return True + return False + + +def _path_part(value: str) -> str | None: + if ":/" not in value: + return value if value.startswith("/") else None + return "/" + value.split(":/", 1)[1].strip("/") + + +def _secret_json_paths(collection: CollectionConfig) -> tuple[str, ...]: + paths = [] + for item in collection.secret_fields: + value = str(item).strip() + if not value: + continue + if value.startswith("/"): + paths.append(value) + else: + paths.append("/" + value.replace(".", "/").strip("/")) + return tuple(paths) diff --git a/plugins/cfg_agent/cfg_agent/resources.py b/plugins/cfg_agent/cfg_agent/resources.py new file mode 100644 index 0000000..7a2c5a6 --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/resources.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ResourceRef: + collection: str + record_id: str | None = None + path: str | None = None + + @property + def scope(self) -> str: + if self.record_id is None: + return "collection" + if self.path is None: + return "record" + return "field" + + def format(self) -> str: + if self.record_id is None: + return f"{self.collection}:*" + if self.path is None: + return f"{self.collection}:{self.record_id}" + return f"{self.collection}:{self.record_id}:{self.path}" + + +def parse_resource(raw: str) -> ResourceRef: + value = str(raw or "").strip() + if ":" not in value: + raise ValueError("resource must look like collection:id, collection:*, or collection:id:/path") + collection, rest = value.split(":", 1) + if not collection or not rest: + raise ValueError("resource collection and id are required") + if rest == "*": + return ResourceRef(collection=collection) + if ":/" in rest: + record_id, path = rest.split(":/", 1) + if not record_id or not path: + raise ValueError("field resource must include record id and JSON path") + return ResourceRef(collection=collection, record_id=record_id, path="/" + path.strip("/")) + return ResourceRef(collection=collection, record_id=rest) + + +def paths_overlap(left: str | None, right: str | None) -> bool: + if left is None or right is None: + return True + left_parts = _path_parts(left) + right_parts = _path_parts(right) + shorter = min(len(left_parts), len(right_parts)) + return left_parts[:shorter] == right_parts[:shorter] + + +def resources_overlap(left: ResourceRef | str, right: ResourceRef | str) -> bool: + left_ref = parse_resource(left) if isinstance(left, str) else left + right_ref = parse_resource(right) if isinstance(right, str) else right + if left_ref.collection != right_ref.collection: + return False + if left_ref.record_id is None or right_ref.record_id is None: + return True + if left_ref.record_id != right_ref.record_id: + return False + return paths_overlap(left_ref.path, right_ref.path) + + +def _path_parts(path: str) -> tuple[str, ...]: + normalized = "/" + path.strip("/") + if normalized == "/": + return () + return tuple(part for part in normalized.split("/") if part) diff --git a/plugins/cfg_agent/cfg_agent/state.py b/plugins/cfg_agent/cfg_agent/state.py new file mode 100644 index 0000000..b024e97 --- /dev/null +++ b/plugins/cfg_agent/cfg_agent/state.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from threading import RLock +from typing import Any, Protocol, runtime_checkable +from uuid import uuid4 + +from cfg_agent.resources import parse_resource, resources_overlap + + +class AgentStateError(RuntimeError): + def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.details = details or {} + + +@dataclass(frozen=True) +class IdempotencyResult: + replay: bool + result: dict[str, Any] | None + + +@runtime_checkable +class AgentStateAdapter(Protocol): + def init_agent_state(self) -> None: ... + def create_session(self, session: dict[str, Any]) -> dict[str, Any]: ... + def heartbeat_session(self, session_id: str, now: datetime) -> dict[str, Any]: ... + def close_session(self, session_id: str, status: str, now: datetime) -> dict[str, Any]: ... + def get_session(self, session_id: str) -> dict[str, Any] | None: ... + def list_sessions(self, status: str | None = None) -> list[dict[str, Any]]: ... + def acquire_lease(self, lease: dict[str, Any], *, now: datetime) -> dict[str, Any]: ... + def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict[str, Any]: ... + def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: ... + def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: ... + def open_intent(self, intent: dict[str, Any]) -> dict[str, Any]: ... + def close_intent(self, intent_id: str, status: str, now: datetime) -> dict[str, Any]: ... + def get_intent(self, intent_id: str) -> dict[str, Any] | None: ... + def list_intents(self, status: str | None = None) -> list[dict[str, Any]]: ... + def remember_idempotency(self, key: str, payload_hash: str, result: dict[str, Any], now: datetime) -> IdempotencyResult: ... + def get_idempotency(self, key: str) -> dict[str, Any] | None: ... + def open_conflict(self, conflict: dict[str, Any]) -> dict[str, Any]: ... + def resolve_conflict(self, conflict_id: str, resolution: str, now: datetime) -> dict[str, Any]: ... + def list_conflicts(self, status: str | None = None) -> list[dict[str, Any]]: ... + def append_event(self, event: dict[str, Any]) -> None: ... + def list_events(self, *, since_event_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: ... + + +class InMemoryAgentStateAdapter: + """Reference state adapter for tests and local MCP sessions. + + The production adapters will store the same logical state in Mongo/Postgres. + """ + + def __init__(self) -> None: + self._lock = RLock() + self.sessions: dict[str, dict[str, Any]] = {} + self.leases: dict[str, dict[str, Any]] = {} + self.intents: dict[str, dict[str, Any]] = {} + self.conflicts: dict[str, dict[str, Any]] = {} + self.idempotency: dict[str, dict[str, Any]] = {} + self.events: list[dict[str, Any]] = [] + + def init_agent_state(self) -> None: + return None + + def create_session(self, session: dict[str, Any]) -> dict[str, Any]: + with self._lock: + item = deepcopy(session) + self.sessions[item["session_id"]] = item + return deepcopy(item) + + def heartbeat_session(self, session_id: str, now: datetime) -> dict[str, Any]: + with self._lock: + session = self._require(self.sessions, session_id, "session_not_found") + session["heartbeat_at"] = _iso(now) + return deepcopy(session) + + def close_session(self, session_id: str, status: str, now: datetime) -> dict[str, Any]: + with self._lock: + session = self._require(self.sessions, session_id, "session_not_found") + session["status"] = status + session["ended_at"] = _iso(now) + session["heartbeat_at"] = _iso(now) + return deepcopy(session) + + def get_session(self, session_id: str) -> dict[str, Any] | None: + with self._lock: + return deepcopy(self.sessions.get(session_id)) + + def list_sessions(self, status: str | None = None) -> list[dict[str, Any]]: + with self._lock: + rows = list(self.sessions.values()) + if status: + rows = [row for row in rows if row.get("status") == status] + return deepcopy(sorted(rows, key=lambda row: row["started_at"], reverse=True)) + + def acquire_lease(self, lease: dict[str, Any], *, now: datetime) -> dict[str, Any]: + with self._lock: + self._expire_leases(now) + resource = parse_resource(lease["resource"]) + conflicts = [] + for existing in self.leases.values(): + if existing.get("status") != "active": + continue + if existing.get("session_id") == lease["session_id"]: + continue + if resources_overlap(resource, existing["resource"]): + conflicts.append(deepcopy(existing)) + if conflicts: + raise AgentStateError( + "lease_conflict", + "another active lease overlaps this resource", + {"leases": conflicts}, + ) + item = deepcopy(lease) + self.leases[item["lease_id"]] = item + return deepcopy(item) + + def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict[str, Any]: + with self._lock: + lease = self._require(self.leases, lease_id, "lease_not_found") + if lease.get("status") != "active": + raise AgentStateError("lease_not_active", "lease is not active", {"lease_id": lease_id}) + lease["expires_at"] = _iso(now + timedelta(seconds=ttl_seconds)) + return deepcopy(lease) + + def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: + with self._lock: + lease = self._require(self.leases, lease_id, "lease_not_found") + if lease.get("status") == "active": + lease["status"] = "released" + lease["released_at"] = _iso(now) + return deepcopy(lease) + + def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: + with self._lock: + self._expire_leases(now or utcnow()) + rows = list(self.leases.values()) + if active_only: + rows = [row for row in rows if row.get("status") == "active"] + return deepcopy(sorted(rows, key=lambda row: row["created_at"], reverse=True)) + + def open_intent(self, intent: dict[str, Any]) -> dict[str, Any]: + with self._lock: + item = deepcopy(intent) + self.intents[item["intent_id"]] = item + return deepcopy(item) + + def close_intent(self, intent_id: str, status: str, now: datetime) -> dict[str, Any]: + with self._lock: + intent = self._require(self.intents, intent_id, "intent_not_found") + intent["status"] = status + intent["closed_at"] = _iso(now) + return deepcopy(intent) + + def get_intent(self, intent_id: str) -> dict[str, Any] | None: + with self._lock: + return deepcopy(self.intents.get(intent_id)) + + def list_intents(self, status: str | None = None) -> list[dict[str, Any]]: + with self._lock: + rows = list(self.intents.values()) + if status: + rows = [row for row in rows if row.get("status") == status] + return deepcopy(sorted(rows, key=lambda row: row["created_at"], reverse=True)) + + def remember_idempotency(self, key: str, payload_hash: str, result: dict[str, Any], now: datetime) -> IdempotencyResult: + with self._lock: + existing = self.idempotency.get(key) + if existing: + if existing.get("payload_hash") != payload_hash: + raise AgentStateError( + "idempotency_conflict", + "idempotency key was already used with a different payload", + {"key": key}, + ) + return IdempotencyResult(replay=True, result=deepcopy(existing.get("result"))) + self.idempotency[key] = { + "kind": "idempotency", + "idempotency_key": key, + "payload_hash": payload_hash, + "result": deepcopy(result), + "created_at": _iso(now), + } + return IdempotencyResult(replay=False, result=None) + + def get_idempotency(self, key: str) -> dict[str, Any] | None: + with self._lock: + return deepcopy(self.idempotency.get(key)) + + def open_conflict(self, conflict: dict[str, Any]) -> dict[str, Any]: + with self._lock: + item = deepcopy(conflict) + self.conflicts[item["conflict_id"]] = item + return deepcopy(item) + + def resolve_conflict(self, conflict_id: str, resolution: str, now: datetime) -> dict[str, Any]: + with self._lock: + conflict = self._require(self.conflicts, conflict_id, "conflict_not_found") + conflict["status"] = "resolved" + conflict["resolution"] = resolution + conflict["resolved_at"] = _iso(now) + return deepcopy(conflict) + + def list_conflicts(self, status: str | None = None) -> list[dict[str, Any]]: + with self._lock: + rows = list(self.conflicts.values()) + if status: + rows = [row for row in rows if row.get("status") == status] + return deepcopy(sorted(rows, key=lambda row: row["created_at"], reverse=True)) + + def append_event(self, event: dict[str, Any]) -> None: + with self._lock: + self.events.append(deepcopy(event)) + + def list_events(self, *, since_event_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + with self._lock: + rows = self.events + if since_event_id: + for index, event in enumerate(rows): + if event.get("event_id") == since_event_id: + rows = rows[index + 1 :] + break + return deepcopy(rows[-limit:]) + + def _expire_leases(self, now: datetime) -> None: + for lease in self.leases.values(): + if lease.get("status") != "active": + continue + expires_at = _parse_time(lease["expires_at"]) + if expires_at <= now: + lease["status"] = "expired" + + @staticmethod + def _require(items: dict[str, dict[str, Any]], key: str, code: str) -> dict[str, Any]: + item = items.get(key) + if item is None: + raise AgentStateError(code, f"{key} was not found", {"id": key}) + return item + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:16]}" + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +def _iso(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _parse_time(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) diff --git a/plugins/cfg_agent/pyproject.toml b/plugins/cfg_agent/pyproject.toml new file mode 100644 index 0000000..90e6bd5 --- /dev/null +++ b/plugins/cfg_agent/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "cfgit-agent" +version = "0.1.0" +description = "Optional cfgit plugin for multi-agent coordination over live database records" +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +authors = [{ name = "Mohammad Ausaf" }] +dependencies = [ + "cfgit>=0.1.2,<0.2.0", +] + +[project.optional-dependencies] +mongo = ["cfgit[mongo]>=0.1.2,<0.2.0"] +postgres = ["cfgit[postgres]>=0.1.2,<0.2.0"] +mcp = ["cfgit[mcp]>=0.1.2,<0.2.0"] +all = [ + "cfgit[mongo,postgres,mcp]>=0.1.2,<0.2.0", +] + +[project.urls] +Homepage = "https://github.com/AusafMo/cfgit" +Repository = "https://github.com/AusafMo/cfgit" +Issues = "https://github.com/AusafMo/cfgit/issues" +Documentation = "https://github.com/AusafMo/cfgit/tree/main/plugins/cfg_agent#readme" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["cfg_agent"] diff --git a/pyproject.toml b/pyproject.toml index 64903b9..cda79d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ postgres = ["psycopg[binary]>=3.2"] cli = ["click>=8.1", "rich>=13.0"] mcp = ["mcp>=1.0"] impact = [] # the cfgit-impact plugin declares its own model-client deps; never in core -dev = ["pytest>=8.0", "pytest-asyncio", "ruff", "mypy", "httpx>=0.27"] +dev = ["pytest>=8.0", "pytest-asyncio", "ruff", "mypy", "httpx>=0.27", "build", "twine", "hatchling"] [project.urls] Homepage = "https://github.com/AusafMo/cfgit" diff --git a/skills/cfgit/SKILL.md b/skills/cfgit/SKILL.md index 29deb7f..8cf7fa5 100644 --- a/skills/cfgit/SKILL.md +++ b/skills/cfgit/SKILL.md @@ -13,6 +13,7 @@ Use cfgit as the safety layer around a live datastore. The app still reads and w - Never use cross-project Mongo URIs for writes. Remote managed Mongo writes are forbidden unless the user explicitly grants a per-turn exception and asks for that exact target. - Treat `changed_outside_cfgit` as the central state. Do not commit over it. Run `cfg diff`, explain what changed, then `cfg adopt` if the user wants to fold that live state into history. - If `[branches] enabled = true`, draft risky changes on a non-main branch and open a cfgit PR. Branch commits and PR creation do not mutate runtime; `cfg pr merge` is the only branch command that does. +- If `cfgit-agent` is installed and `[agent] enabled = true`, use the `cfg_agent_*` MCP tools before mutation: start a session, claim the narrowest resource/path, open an intent, validate the JSON Patch, then apply it through cfgit. Do not raw-write the database from an agent session. - Prefer `--json` for agent parsing. - Use deterministic `cfg impact` first. Add `--llm` only when the user asks for LLM narration and the impact plugin is installed. Use `--against ` when the user wants narration scoped to specific related records instead of the whole system. - Before the FIRST `cfg import` against a new database or `.cfg.toml`, run `cfg doctor` (read-only). It reports every secret-deny match, oversized field, and key issue at once, with paste-ready `secret_fields`/`ignore_fields` snippets. Fix `.cfg.toml` from its output, then import. This avoids import failing one secret at a time. @@ -64,6 +65,17 @@ Use cfgit as the safety layer around a live datastore. The app still reads and w - `cfg pr create --base main --head -m "" --json` - `cfg pr merge --json` only after review. If merge returns `stale` or `changed_outside_cfgit`, do not retry blindly; inspect current main/live drift first. +7. Multi-agent coordination when `cfgit-agent` is enabled. + - `cfg_agent_start_session(task, agent_id, config_file, env)` + - `cfg_agent_claim(session_id, "collection:id:/json/path", config_file, env)` + - `cfg_agent_open_intent(session_id, resources, summary, planned_paths, expected_base, config_file, env)` + - `cfg_agent_validate_patch(session_id, record, patch, intent_id, config_file, env)` + - `cfg_agent_apply_patch(session_id, record, patch, intent_id, message, idempotency_key, config_file, env)` + - `cfg_agent_end_session(session_id, status, summary, config_file, env)` + - Claim fields, not whole records, when another agent could safely work on sibling paths. + - If apply returns `state="review_requested"`, do not expect live runtime to change. A cfgit branch/PR was opened for human merge. + - If a claim, policy, base, or live-drift conflict is returned, surface it and stop; do not bypass with raw DB writes. + ## MCP Tools If the cfgit MCP server is available, prefer its tools over shelling out: @@ -95,6 +107,21 @@ If the cfgit MCP server is available, prefer its tools over shelling out: - `cfg_init` - `cfg_identity_hash` for setup only. Prefer the local CLI for real tokens because MCP clients may log tool inputs. +If the optional `cfgit-agent` MCP server is installed, it also exposes: + +- `cfg_agent_start_session` +- `cfg_agent_heartbeat` +- `cfg_agent_end_session` +- `cfg_agent_claim` +- `cfg_agent_release` +- `cfg_agent_open_intent` +- `cfg_agent_close_intent` +- `cfg_agent_validate_patch` +- `cfg_agent_apply_patch` +- `cfg_agent_status` +- `cfg_agent_conflicts` +- `cfg_agent_watch` + Every MCP tool returns the same envelope shape as the CLI exit status: `status`, `code`, `message`, `data`. For MCP bulk commits, pass `items` as structured JSON (`[{record, doc}]`) when the client supports it; use a JSON string only when the client cannot send nested objects cleanly. For MCP history/env mismatches, branch on `status="error"` and `code=6`; surface the `message` to the user and do not continue mutation. diff --git a/src/cfg/ui/server.py b/src/cfg/ui/server.py index 0d79da0..5fd1255 100644 --- a/src/cfg/ui/server.py +++ b/src/cfg/ui/server.py @@ -62,10 +62,17 @@ def do_GET(self) -> None: # noqa: N802 params = parse_qs(parsed.query) self._send_json(self._state(params)) return + if parsed.path == "/api/agent/state": + params = parse_qs(parsed.query) + self._send_json(self._agent_state(params)) + return self.send_error(HTTPStatus.NOT_FOUND) def do_POST(self) -> None: # noqa: N802 parsed = urlparse(self.path) + if parsed.path == "/api/agent/action": + self._agent_action() + return if parsed.path != "/api/action": self.send_error(HTTPStatus.NOT_FOUND) return @@ -81,6 +88,77 @@ def do_POST(self) -> None: # noqa: N802 status=HTTPStatus.INTERNAL_SERVER_ERROR, ) + def _agent_state(self, params: dict[str, list[str]]) -> dict[str, Any]: + try: + from cfg_agent.config import cached_agent_coordinator, load_agent_config + + ctx = self._ctx(params=params) + agent_cfg = load_agent_config(ctx.config_file) + if not agent_cfg.enabled: + return { + "status": "disabled", + "code": actions.EXIT_OK, + "message": "cfgit-agent is not enabled in [agent]", + "data": { + "enabled": False, + "backend": agent_cfg.state_backend, + "state": None, + "events": [], + }, + } + coordinator = cached_agent_coordinator(ctx.config_file, ctx.env) + return { + "status": "ok", + "code": actions.EXIT_OK, + "message": "", + "data": { + "enabled": True, + "backend": agent_cfg.state_backend, + "state": actions.to_json(coordinator.status()), + "events": actions.to_json(coordinator.watch(limit=100)), + }, + } + except ModuleNotFoundError: + return { + "status": "unavailable", + "code": actions.EXIT_STORAGE, + "message": "install cfgit-agent to use the agent coordination UI", + "data": None, + } + except Exception as exc: + return {"status": "error", "code": actions.EXIT_STORAGE, "message": str(exc), "data": None} + + def _agent_action(self) -> None: + try: + from cfg_agent.actions import AgentActions + from cfg_agent.config import cached_agent_coordinator + + payload = self._read_json() + name = str(payload.get("action") or "") + if name not in _AGENT_UI_ACTIONS: + raise ValueError(f"unknown agent action: {name}") + ctx = self._ctx(payload) + action_payload = {key: value for key, value in payload.items() if key not in {"action", "env", "config_file", "author"}} + if name in {"validate_patch", "apply_patch"}: + action_payload["engine"] = actions.make_engine(ctx) + result = getattr(AgentActions(cached_agent_coordinator(ctx.config_file, ctx.env)), name)(action_payload) + self._send_json(result) + except ModuleNotFoundError: + self._send_json( + { + "status": "error", + "code": actions.EXIT_STORAGE, + "message": "install cfgit-agent to use agent coordination actions", + "data": None, + }, + status=HTTPStatus.BAD_REQUEST, + ) + except Exception as exc: + self._send_json( + {"status": "error", "code": actions.EXIT_STORAGE, "message": str(exc), "data": None}, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + def _ctx(self, payload: dict[str, Any] | None = None, params: dict[str, list[str]] | None = None) -> ActionContext: payload = payload or {} params = params or {} @@ -245,6 +323,22 @@ def _first(params: dict[str, list[str]], key: str) -> str | None: return values[0] or None +_AGENT_UI_ACTIONS = { + "start_session", + "heartbeat", + "end_session", + "claim", + "release", + "open_intent", + "close_intent", + "status", + "conflicts", + "watch", + "validate_patch", + "apply_patch", +} + + def find_free_port(host: str = DEFAULT_HOST) -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind((host, 0)) @@ -548,6 +642,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: .mbg.show{display:flex} .modal{background:var(--panel);border:1px solid var(--edge2);border-radius:14px;width:min(540px,92vw); box-shadow:var(--shadow);overflow:hidden} + .modal.wide{width:min(920px,94vw)} .modal h3{margin:0;padding:16px 18px;font-family:var(--disp);font-weight:600;font-size:15px;border-bottom:1px solid var(--edge)} .modal .b{padding:16px 18px;display:flex;flex-direction:column;gap:12px} .modal .desc{color:var(--dim);font-size:13px;line-height:1.55} @@ -557,6 +652,21 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: .modal textarea{width:100%;min-height:280px;resize:vertical;background:var(--bg);border:1px solid var(--edge2);border-radius:8px;padding:9px 11px;font-family:var(--mono);font-size:12px;color:var(--ink)} .modal input:focus,.modal textarea:focus{outline:none;border-color:var(--blue)} .modal .f{display:flex;justify-content:flex-end;gap:9px;padding:14px 18px;border-top:1px solid var(--edge)} + .agentbar{display:flex;align-items:center;gap:9px;flex-wrap:wrap} + .agentgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} + .agentsec{border:1px solid var(--edge);border-radius:10px;background:var(--bg);overflow:hidden;min-width:0} + .agentsec h4{margin:0;padding:9px 11px;border-bottom:1px solid var(--edge);font-family:var(--mono); + font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--faint)} + .agentlist{max-height:260px;overflow:auto} + .agentitem{padding:10px 11px;border-bottom:1px solid var(--edge);display:flex;gap:10px;align-items:flex-start} + .agentitem:last-child{border-bottom:0} + .agentmain{flex:1;min-width:0} + .agenttitle{font-family:var(--mono);font-size:12px;word-break:break-all} + .agentmeta{margin-top:4px;color:var(--dim);font-size:11.5px;display:flex;gap:7px;flex-wrap:wrap} + .agentact{border:1px solid var(--edge2);background:transparent;color:var(--dim);border-radius:7px; + padding:4px 8px;font-size:11.5px;cursor:pointer;white-space:nowrap} + .agentact:hover{color:var(--ink);border-color:var(--blue)} + .agentempty{padding:16px 11px;color:var(--faint);font-size:12px} @media (max-width:1080px){ .cols{grid-template-columns:minmax(220px,230px) minmax(250px,280px) minmax(0,1fr)} @@ -598,6 +708,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: .dcell{grid-template-columns:30px 14px 1fr} .impact .ib{padding:12px} .modal textarea{min-height:220px} + .agentgrid{grid-template-columns:1fr} } @media (prefers-reduced-motion:reduce){ *{animation:none!important;transition:none!important} } @@ -622,6 +733,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int:
+
@@ -1081,7 +1193,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: $("diff").innerHTML=`
document
${esc(txt)}
`;} /* modals */ -function modal(html){$("modal").innerHTML=html;$("mbg").classList.add("show");} +function modal(html,wide=false){$("modal").className="modal"+(wide?" wide":"");$("modal").innerHTML=html;$("mbg").classList.add("show");} function closeModal(){$("mbg").classList.remove("show");} $("mbg").addEventListener("click",e=>{if(e.target===$("mbg"))closeModal();}); function openAdopt(rec){modal(`

Adopt out-of-band change

@@ -1186,6 +1298,85 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: toast(ok?verb:(res&&res.message?res.message:verb+" failed"),!ok); const keep=S.sel;await loadState();if(keep)selectRecord(keep);} +async function agentFetch(){ + return fetch("/api/agent/state?"+qs()).then(r=>r.json()).catch(e=>({status:"error",message:String(e)})); +} +async function agentAct(action,data){ + const r=await fetch("/api/agent/action",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action,...env(),...data})}).then(x=>x.json()); + if(r.status!=="ok")toast(r.message||"agent action failed",true);else toast("agent state updated"); + await renderAgents(); +} +function openAgents(){ + modal(`

Agent coordination

loading agent state...
+
`,true); + renderAgents(); +} +async function renderAgents(){ + const body=$("agentBody"); if(!body)return; + const res=await agentFetch(); + if(res.status==="disabled"||res.status==="unavailable"){ + body.innerHTML=`
${esc(res.message||"cfgit-agent is not available")}
+
Enable [agent] and install cfgit-agent to see live sessions, leases, intents, conflicts, and events here.
`; + return; + } + if(res.status!=="ok"){ + body.innerHTML=`
${esc(res.message||"could not load agent state")}
`; + return; + } + const data=res.data||{}, state=data.state||{}, events=data.events||[]; + const sessions=state.sessions||[], leases=state.leases||[], intents=state.intents||[], conflicts=state.conflicts||[]; + body.innerHTML=`
+ enabled + ${esc(data.backend||"memory")} + coordination state lives beside cfgit history +
+
+ ${agentSection("Sessions",sessions.map(agentSessionItem).join(""),"No sessions.")} + ${agentSection("Claims",leases.map(agentLeaseItem).join(""),"No leases.")} + ${agentSection("Intents",intents.map(agentIntentItem).join(""),"No intents.")} + ${agentSection("Conflicts",conflicts.map(agentConflictItem).join(""),"No conflicts.")} +
+ ${agentSection("Event feed",events.slice().reverse().map(agentEventItem).join(""),"No events yet.")}`; + bindAgentActions(); +} +function agentSection(title,html,empty){ + return `

${esc(title)}

${html||`
${esc(empty)}
`}
`; +} +function agentSessionItem(s){ + const running=s.status==="running"||s.status==="blocked"; + return `
${esc(s.agent_id||s.session_id)}
+
${esc(s.status)}${esc(s.task||"")}${esc(s.session_id||"")}
+ ${running?``:""}
`; +} +function agentLeaseItem(l){ + const active=l.status==="active"; + return `
${esc(l.resource||"")}
+
${esc(l.status)}${esc(l.session_id||"")}expires ${esc(l.expires_at||"")}
+ ${active?``:""}
`; +} +function agentIntentItem(i){ + const open=i.status==="open"; + return `
${esc(i.summary||i.intent_id)}
+
${esc(i.status)}${esc((i.resources||[]).join(", "))}${esc((i.planned_paths||[]).join(", "))}
+ ${open?``:""}
`; +} +function agentConflictItem(c){ + return `
${esc(c.type||"conflict")} ยท ${esc(c.resource||"")}
+
${esc(c.status)}${esc(c.message||"")}${esc(c.conflict_id||"")}
`; +} +function agentEventItem(e){ + return `
${esc(e.event||"event")}
+
${esc(e.resource||"")}${esc(e.actor||"")}${esc(e.recorded_at||"")}
`; +} +function bindAgentActions(){ + document.querySelectorAll("[data-agent-act]").forEach(b=>b.onclick=()=>{ + const act=b.dataset.agentAct; + if(act==="end_session")agentAct("end_session",{session_id:b.dataset.session,status:"abandoned",summary:"ended from cfgit UI"}); + if(act==="release")agentAct("release",{session_id:b.dataset.session,lease_id:b.dataset.lease}); + if(act==="close_intent")agentAct("close_intent",{session_id:b.dataset.session,intent_id:b.dataset.intent,status:"abandoned"}); + }); +} + /* wiring */ $("find").addEventListener("input",e=>{S.q=e.target.value;renderTree();}); $("refresh").onclick=()=>{const keep=S.sel;loadState().then(()=>{if(keep)selectRecord(keep);});}; @@ -1196,6 +1387,7 @@ def find_free_port(host: str = DEFAULT_HOST) -> int: $("branchDiff").onclick=()=>showBranchDiff(); $("openPr").onclick=()=>openPrModal(); $("mergePr").onclick=()=>openMergeModal(); +$("agents").onclick=()=>openAgents(); loadState(); diff --git a/tests/test_agent_coordination.py b/tests/test_agent_coordination.py new file mode 100644 index 0000000..c7d1928 --- /dev/null +++ b/tests/test_agent_coordination.py @@ -0,0 +1,908 @@ +from __future__ import annotations + +import os +import pathlib +import sys +from datetime import datetime, timezone +from uuid import uuid4 +from urllib.parse import urlparse + +import pytest + + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "plugins" / "cfg_agent")) + + +def test_resource_overlap_rules_are_field_aware() -> None: + from cfg_agent.resources import paths_overlap, resources_overlap + + assert paths_overlap("/instructions", "/instructions/body") + assert not paths_overlap("/instructions/body", "/instructions/title") + assert resources_overlap("agent_configs:refund:/instructions", "agent_configs:refund") + assert resources_overlap("agent_configs:*", "agent_configs:refund:/instructions") + assert not resources_overlap("agent_configs:refund:/instructions", "agent_configs:refund:/tools") + assert not resources_overlap("agent_configs:refund:/instructions", "modelgarden_models:refund:/instructions") + + +def test_session_lifecycle_emits_events() -> None: + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter + + adapter = InMemoryAgentStateAdapter() + coordinator = AgentCoordinator(adapter) + + session = coordinator.start_session( + task="edit refund config", + agent_id="agent.refund", + agent_kind="codex", + actor="agent.refund@runtime", + ) + coordinator.heartbeat(session["session_id"]) + closed = coordinator.end_session(session["session_id"], status="completed", summary="done") + + assert closed["status"] == "completed" + assert [event["event"] for event in coordinator.watch()] == [ + "session.started", + "session.heartbeat", + "session.ended", + ] + + +def test_non_overlapping_field_claims_can_run_in_parallel() -> None: + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter + + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + left = coordinator.start_session(task="instructions", agent_id="agent.a") + right = coordinator.start_session(task="threshold", agent_id="agent.b") + + left_lease = coordinator.claim( + session_id=left["session_id"], + resource="agent_configs:refund_resolution:/instructions", + reason="copy edit", + ) + right_lease = coordinator.claim( + session_id=right["session_id"], + resource="agent_configs:refund_resolution:/automation_threshold", + reason="threshold edit", + ) + + assert left_lease["status"] == "active" + assert right_lease["status"] == "active" + assert coordinator.conflicts() == [] + + +def test_overlapping_claim_creates_structured_conflict() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + left = coordinator.start_session(task="instructions", agent_id="agent.a") + right = coordinator.start_session(task="record", agent_id="agent.b") + + coordinator.claim( + session_id=left["session_id"], + resource="agent_configs:refund_resolution:/instructions", + reason="copy edit", + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.claim( + session_id=right["session_id"], + resource="agent_configs:refund_resolution", + reason="whole record rewrite", + ) + + assert raised.value.code == "lease_conflict" + conflicts = coordinator.conflicts(status="open") + assert len(conflicts) == 1 + assert conflicts[0]["type"] == "lease_conflict" + assert conflicts[0]["resource"] == "agent_configs:refund_resolution" + + +def test_intent_tracks_planned_paths_and_can_close() -> None: + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter + + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="refund edit", agent_id="agent.refund") + + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["agent_configs:refund_resolution"], + summary="Edit refund language", + planned_paths=["/instructions"], + expected_base={"agent_configs:refund_resolution": {"head_seq": 3}}, + idempotency_key="task-1:intent", + ) + closed = coordinator.close_intent( + session_id=session["session_id"], + intent_id=intent["intent_id"], + status="committed", + ) + + assert intent["status"] == "open" + assert intent["planned_paths"] == ["/instructions"] + assert closed["status"] == "committed" + + +def test_idempotency_replays_same_payload_and_blocks_different_payload() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + first = coordinator.remember_idempotency( + key="session-1:patch-1", + payload={"record": "demo:a", "patch": [{"op": "replace", "path": "/x", "value": 1}]}, + result={"commit": "a@2"}, + ) + replay = coordinator.remember_idempotency( + key="session-1:patch-1", + payload={"record": "demo:a", "patch": [{"op": "replace", "path": "/x", "value": 1}]}, + result={"commit": "a@2"}, + ) + + assert first == {"replay": False, "result": {"commit": "a@2"}} + assert replay == {"replay": True, "result": {"commit": "a@2"}} + + with pytest.raises(AgentStateError) as raised: + coordinator.remember_idempotency( + key="session-1:patch-1", + payload={"record": "demo:a", "patch": [{"op": "replace", "path": "/x", "value": 2}]}, + result={"commit": "a@3"}, + ) + assert raised.value.code == "idempotency_conflict" + + +def test_agent_actions_return_cfgit_style_envelopes() -> None: + from cfg_agent.actions import AgentActions + + actions = AgentActions() + started = actions.start_session({"task": "edit config", "agent_id": "agent.a"}) + session_id = started["data"]["session_id"] + claimed = actions.claim( + { + "session_id": session_id, + "resource": "agent_configs:refund_resolution:/instructions", + "reason": "copy edit", + } + ) + intent = actions.open_intent( + { + "session_id": session_id, + "resources": ["agent_configs:refund_resolution"], + "summary": "Edit refund language", + "planned_paths": ["/instructions"], + } + ) + + assert started["status"] == "ok" + assert claimed["data"]["scope"] == "field" + assert intent["data"]["status"] == "open" + + +def test_agent_mcp_tools_use_agent_envelopes() -> None: + pytest.importorskip("mcp") + from cfg_agent import mcp as agent_mcp + + agent_mcp.reset_for_tests() + started = agent_mcp.cfg_agent_start_session(task="edit config", agent_id="agent.a") + session_id = started["data"]["session_id"] + claimed = agent_mcp.cfg_agent_claim( + session_id=session_id, + resource="agent_configs:refund_resolution:/instructions", + reason="copy edit", + ) + status = agent_mcp.cfg_agent_status(session_id=session_id) + + assert started["status"] == "ok" + assert claimed["data"]["scope"] == "field" + assert status["data"]["session"]["session_id"] == session_id + + +def test_validate_patch_returns_patched_doc_and_diff() -> None: + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter + + engine, row = _clean_engine({"id": "alpha", "value": 1, "nested": {"mode": "old"}}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/value") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + result = coordinator.validate_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["intent_id"], + ) + + assert result["state"] == "ok" + assert result["patched_doc"]["value"] == 2 + assert result["changes"] == [{"path": "value", "op": "change", "before": 1, "after": 2}] + assert coordinator.watch()[-1]["event"] == "patch.validated" + + +def test_validate_patch_supports_add_and_remove_ops() -> None: + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter + + engine, row = _clean_engine({"id": "alpha", "tools": ["search"], "old": True}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit tools", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change tools", + planned_paths=["/tools", "/old"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + result = coordinator.validate_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[ + {"op": "add", "path": "/tools/-", "value": "handoff"}, + {"op": "remove", "path": "/old"}, + ], + intent_id=intent["intent_id"], + ) + + assert result["patched_doc"] == {"id": "alpha", "tools": ["search", "handoff"]} + + +def test_validate_patch_requires_claim_for_each_path() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + engine, row = _clean_engine({"id": "alpha", "value": 1, "other": 1}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/value") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value and other", + planned_paths=["/value", "/other"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.validate_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[ + {"op": "replace", "path": "/value", "value": 2}, + {"op": "replace", "path": "/other", "value": 2}, + ], + intent_id=intent["intent_id"], + ) + + assert raised.value.code == "claim_required" + assert raised.value.details["conflict"]["type"] == "claim_required" + + +def test_validate_patch_blocks_paths_outside_intent() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + engine, row = _clean_engine({"id": "alpha", "value": 1, "other": 1}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.validate_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/other", "value": 2}], + intent_id=intent["intent_id"], + ) + + assert raised.value.code == "intent_scope" + + +def test_validate_patch_blocks_stale_base() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + engine, _row = _clean_engine({"id": "alpha", "value": 1}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/value") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": 999}}, + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.validate_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["intent_id"], + ) + + assert raised.value.code == "base_moved" + + +def test_validate_patch_blocks_live_drift() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + engine, row = _clean_engine( + {"id": "alpha", "value": 1}, + live_doc={"id": "alpha", "value": 99}, + ) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/value") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.validate_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["intent_id"], + ) + + assert raised.value.code == "live_drift" + assert raised.value.details["conflict"]["details"]["live_oid"] != row["oid"] + + +def test_policy_blocks_configured_secret_patch_paths() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + engine, row = _clean_engine( + {"id": "alpha", "provider_config": {"api_key": "secret", "timeout": 30}}, + secret_fields=("provider_config.api_key",), + ) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit provider", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change provider", + planned_paths=["/provider_config/api_key"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.validate_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/provider_config/api_key", "value": "new-secret"}], + intent_id=intent["intent_id"], + ) + + assert raised.value.code == "policy_blocked" + assert raised.value.details["secret_path"] == "/provider_config/api_key" + + +def test_review_policy_routes_apply_to_branch_pr_without_runtime_mutation() -> None: + from cfg.core.config import BranchesConfig + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter, StaticAgentPolicy + + engine, row = _clean_engine( + {"id": "alpha", "rollout": {"traffic": 10}}, + branches=BranchesConfig(enabled=True), + ) + coordinator = AgentCoordinator( + InMemoryAgentStateAdapter(), + policy=StaticAgentPolicy(review_paths=("/rollout*",)), + ) + session = coordinator.start_session(task="change rollout", agent_id="rollout-agent") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/rollout") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="increase traffic", + planned_paths=["/rollout/traffic"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + result = coordinator.apply_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/rollout/traffic", "value": 25}], + intent_id=intent["intent_id"], + message="review rollout traffic", + idempotency_key="review-key", + ) + + assert result["state"] == "review_requested" + assert result["runtime_mutated"] is False + assert result["pr"]["status"] == "open" + assert result["pr"]["agent"]["session_id"] == session["session_id"] + assert result["pr"]["agent"]["intent_id"] == intent["intent_id"] + assert engine.resolve_ref(row_ref("demo", "alpha"), "=live")["doc"]["rollout"]["traffic"] == 10 + assert coordinator.status()["intents"][0]["status"] == "review_requested" + assert [event["event"] for event in coordinator.watch()][-3:] == [ + "patch.validated", + "patch.routed_to_pr", + "pr.created", + ] + + +def test_review_policy_fails_closed_when_branches_are_disabled() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter, StaticAgentPolicy + + engine, row = _clean_engine({"id": "alpha", "rollout": {"traffic": 10}}) + coordinator = AgentCoordinator( + InMemoryAgentStateAdapter(), + policy=StaticAgentPolicy(review_paths=("/rollout*",)), + ) + session = coordinator.start_session(task="change rollout", agent_id="rollout-agent") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/rollout") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="increase traffic", + planned_paths=["/rollout/traffic"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.apply_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/rollout/traffic", "value": 25}], + intent_id=intent["intent_id"], + message="review rollout traffic", + ) + + assert raised.value.code == "review_unavailable" + assert engine.resolve_ref(row_ref("demo", "alpha"), "=live")["doc"]["rollout"]["traffic"] == 10 + + +def test_role_policy_limits_claim_scope() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter, StaticAgentPolicy + from cfg_agent.policy import AgentRolePolicy + + policy = StaticAgentPolicy( + roles={ + "routing-agent": AgentRolePolicy( + name="routing-agent", + can_claim=("modelgarden_models:*",), + ) + } + ) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter(), policy=policy) + session = coordinator.start_session(task="edit config", agent_id="routing-agent") + + with pytest.raises(AgentStateError) as raised: + coordinator.claim(session_id=session["session_id"], resource="agent_configs:planner:/instructions") + + assert raised.value.code == "policy_blocked" + assert raised.value.details["allowed"] == ["modelgarden_models:*"] + + +def test_agent_config_loads_memory_backend_policy_and_roles(tmp_path: pathlib.Path) -> None: + from cfg_agent.config import load_agent_config + + cfg_file = tmp_path / ".cfg.toml" + cfg_file.write_text( + """ +[project] +name = "agent-test" + +[[collection]] +name = "demo" +id_field = "id" + +[env.dev] +database = "mongo" +uri = "mongodb://localhost:27017/?replicaSet=rs0" +db = "cfgit-agent-test" + +[agent] +enabled = true +state_backend = "memory" +state_collection = "agent_state_test" +events_collection = "agent_events_test" +default_lease_ttl_seconds = 12 + +[agent.policies] +deny_paths = ["/provider_config*"] +require_human_review_for = ["/rollout*"] + +[[agent.role]] +name = "routing-agent" +can_claim = ["modelgarden_models:*"] +review_paths = ["/pricing*"] +""", + encoding="utf-8", + ) + + cfg = load_agent_config(cfg_file) + + assert cfg.enabled is True + assert cfg.state_backend == "memory" + assert cfg.default_lease_ttl_seconds == 12 + assert cfg.deny_paths == ("/provider_config*",) + assert cfg.review_paths == ("/rollout*",) + assert cfg.roles["routing-agent"].can_claim == ("modelgarden_models:*",) + assert cfg.roles["routing-agent"].review_paths == ("/pricing*",) + + +def test_agent_mcp_configured_memory_state_is_shared_across_tools( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + pytest.importorskip("mcp") + from cfg_agent import mcp as agent_mcp + + cfg_file = _agent_memory_config(tmp_path) + agent_mcp.reset_for_tests() + started = agent_mcp.cfg_agent_start_session( + task="edit value", + agent_id="agent.a", + config_file=str(cfg_file), + env="dev", + ) + session_id = started["data"]["session_id"] + claimed = agent_mcp.cfg_agent_claim( + session_id=session_id, + resource="demo:alpha:/value", + config_file=str(cfg_file), + env="dev", + ) + status = agent_mcp.cfg_agent_status(session_id=session_id, config_file=str(cfg_file), env="dev") + + assert claimed["status"] == "ok" + assert status["data"]["session"]["session_id"] == session_id + assert status["data"]["leases"][0]["resource"] == "demo:alpha:/value" + + +def test_validate_patch_action_and_mcp_forward_engine(monkeypatch: pytest.MonkeyPatch) -> None: + pytest.importorskip("mcp") + from cfg_agent import mcp as agent_mcp + + engine, row = _clean_engine({"id": "alpha", "value": 1}) + monkeypatch.setattr(agent_mcp, "make_engine", lambda ctx: engine) + agent_mcp.reset_for_tests() + started = agent_mcp.cfg_agent_start_session(task="edit value", agent_id="agent.a") + session_id = started["data"]["session_id"] + agent_mcp.cfg_agent_claim(session_id=session_id, resource="demo:alpha:/value") + intent = agent_mcp.cfg_agent_open_intent( + session_id=session_id, + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + result = agent_mcp.cfg_agent_validate_patch( + session_id=session_id, + record="demo:alpha", + patch='[{"op":"replace","path":"/value","value":2}]', + intent_id=intent["data"]["intent_id"], + env="dev", + ) + + assert result["status"] == "ok" + assert result["data"]["patched_doc"]["value"] == 2 + + +def test_apply_patch_commits_through_cfgit_core_and_closes_intent() -> None: + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter + + engine, row = _clean_engine({"id": "alpha", "value": 1}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/value") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + result = coordinator.apply_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["intent_id"], + message="agent update value", + idempotency_key="session-1:patch-1", + ) + + assert result["state"] == "applied" + assert result["commit"]["state"] == "committed" + assert engine.resolve_ref(row_ref("demo", "alpha"), "=live")["doc"]["value"] == 2 + assert coordinator.status()["intents"][0]["status"] == "committed" + assert [event["event"] for event in coordinator.watch()][-3:] == [ + "patch.validated", + "patch.applied", + "commit.created", + ] + + +def test_apply_patch_idempotency_replays_after_head_moves() -> None: + from cfg_agent import AgentCoordinator, InMemoryAgentStateAdapter + + engine, row = _clean_engine({"id": "alpha", "value": 1}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/value") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + first = coordinator.apply_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["intent_id"], + message="agent update value", + idempotency_key="same-key", + ) + replay = coordinator.apply_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["intent_id"], + message="agent update value", + idempotency_key="same-key", + ) + + assert first["state"] == "applied" + assert replay["state"] == "replayed" + assert replay["result"]["commit"]["seq"] == first["commit"]["seq"] + + +def test_apply_patch_idempotency_blocks_same_key_with_different_payload() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + engine, row = _clean_engine({"id": "alpha", "value": 1}) + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + session = coordinator.start_session(task="edit value", agent_id="agent.a") + coordinator.claim(session_id=session["session_id"], resource="demo:alpha:/value") + intent = coordinator.open_intent( + session_id=session["session_id"], + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + coordinator.apply_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["intent_id"], + message="agent update value", + idempotency_key="same-key", + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.apply_patch( + engine=engine, + session_id=session["session_id"], + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 3}], + intent_id=intent["intent_id"], + message="agent update value", + idempotency_key="same-key", + ) + + assert raised.value.code == "idempotency_conflict" + + +def test_apply_patch_mcp_tool_commits(monkeypatch: pytest.MonkeyPatch) -> None: + pytest.importorskip("mcp") + from cfg_agent import mcp as agent_mcp + + engine, row = _clean_engine({"id": "alpha", "value": 1}) + monkeypatch.setattr(agent_mcp, "make_engine", lambda ctx: engine) + agent_mcp.reset_for_tests() + started = agent_mcp.cfg_agent_start_session(task="edit value", agent_id="agent.a") + session_id = started["data"]["session_id"] + agent_mcp.cfg_agent_claim(session_id=session_id, resource="demo:alpha:/value") + intent = agent_mcp.cfg_agent_open_intent( + session_id=session_id, + resources=["demo:alpha"], + summary="change value", + planned_paths=["/value"], + expected_base={"demo:alpha": {"head_seq": row["seq"], "head_oid": row["oid"]}}, + ) + + result = agent_mcp.cfg_agent_apply_patch( + session_id=session_id, + record="demo:alpha", + patch=[{"op": "replace", "path": "/value", "value": 2}], + intent_id=intent["data"]["intent_id"], + message="agent update value", + idempotency_key="mcp-key", + env="dev", + ) + + assert result["status"] == "ok" + assert result["data"]["state"] == "applied" + assert engine.resolve_ref(row_ref("demo", "alpha"), "=live")["doc"]["value"] == 2 + + +def test_mongo_agent_state_contract_when_local_uri_is_set() -> None: + uri = os.environ.get("CFGIT_TEST_MONGO_URI") or os.environ.get("CFGIT_MONGODB_URI") or "" + if not _is_local_uri(uri): + pytest.skip("set CFGIT_TEST_MONGO_URI to a local/Docker Mongo URI to run this contract") + pymongo = pytest.importorskip("pymongo") + from cfg.core.config import CollectionConfig, EnvConfig, HistoryConfig, ProjectConfig + from cfg_agent.adapters.mongo import MongoAgentStateAdapter + + suffix = uuid4().hex[:8] + state_collection = f"agent_state_{suffix}" + events_collection = f"agent_events_{suffix}" + project = ProjectConfig( + name="agent-mongo-test", + path=pathlib.Path(".cfg.toml"), + history=HistoryConfig(), + collections=(CollectionConfig(name="demo", id_field="id"),), + envs={"dev": EnvConfig(name="dev", database="mongo", uri=uri, db="cfgit_agent_test")}, + ) + client = pymongo.MongoClient(uri) + try: + adapter = MongoAgentStateAdapter( + project=project, + env_name="dev", + state_collection=state_collection, + events_collection=events_collection, + ) + _exercise_agent_state_adapter(adapter) + finally: + client["cfgit_agent_test"].drop_collection(state_collection) + client["cfgit_agent_test"].drop_collection(events_collection) + + +def test_postgres_agent_state_contract_when_local_uri_is_set() -> None: + uri = os.environ.get("CFGIT_TEST_POSTGRES_URI") or os.environ.get("CFGIT_POSTGRES_URI") or "" + if not _is_local_uri(uri): + pytest.skip("set CFGIT_TEST_POSTGRES_URI to a local/Docker Postgres URI to run this contract") + psycopg = pytest.importorskip("psycopg") + from cfg.core.config import CollectionConfig, EnvConfig, HistoryConfig, ProjectConfig + from cfg_agent.adapters.postgres import PostgresAgentStateAdapter + + suffix = uuid4().hex[:8] + state_table = f"agent_state_{suffix}" + events_table = f"agent_events_{suffix}" + project = ProjectConfig( + name="agent-postgres-test", + path=pathlib.Path(".cfg.toml"), + history=HistoryConfig(), + collections=(CollectionConfig(name="demo", id_field="id"),), + envs={"dev": EnvConfig(name="dev", database="postgres", uri=uri, db="")}, + ) + conn = psycopg.connect(uri, autocommit=True) + try: + adapter = PostgresAgentStateAdapter( + project=project, + env_name="dev", + state_collection=state_table, + events_collection=events_table, + ) + _exercise_agent_state_adapter(adapter) + finally: + with conn.cursor() as cur: + cur.execute(f'DROP TABLE IF EXISTS "{state_table}"') + cur.execute(f'DROP TABLE IF EXISTS "{events_table}"') + conn.close() + + +def _clean_engine( + doc: dict, + *, + live_doc: dict | None = None, + secret_fields: tuple[str, ...] = (), + branches=None, +): + from cfg.core.config import CollectionConfig + from tests.test_engine_safety import _engine, _history_row + + coll = CollectionConfig(name="demo", id_field="id", secret_fields=secret_fields) + row = _history_row(coll, doc, seq=1, valid_from=datetime(2026, 7, 1, tzinfo=timezone.utc)) + engine, _adapter = _engine( + collection=coll, + records={("demo", str(doc["id"])): live_doc or doc}, + history=[row], + heads={("demo", str(doc["id"])): row}, + branches=branches, + ) + return engine, row + + +def row_ref(collection: str, record_id: str): + from cfg.core.engine import RecordRef + + return RecordRef(collection, record_id) + + +def _agent_memory_config(tmp_path: pathlib.Path) -> pathlib.Path: + cfg_file = tmp_path / ".cfg.toml" + cfg_file.write_text( + """ +[project] +name = "agent-mcp-test" + +[[collection]] +name = "demo" +id_field = "id" + +[env.dev] +database = "mongo" +uri = "mongodb://localhost:27017/?replicaSet=rs0" +db = "cfgit-agent-test" + +[agent] +enabled = true +state_backend = "memory" +""", + encoding="utf-8", + ) + return cfg_file + + +def _exercise_agent_state_adapter(adapter) -> None: + from cfg_agent import AgentCoordinator, AgentStateError + + adapter.init_agent_state() + coordinator = AgentCoordinator(adapter) + left = coordinator.start_session(task="left", agent_id="agent.left") + right = coordinator.start_session(task="right", agent_id="agent.right") + coordinator.claim(session_id=left["session_id"], resource="demo:alpha:/value") + coordinator.claim(session_id=right["session_id"], resource="demo:alpha:/other") + with pytest.raises(AgentStateError) as raised: + coordinator.claim(session_id=right["session_id"], resource="demo:alpha") + coordinator.remember_idempotency( + key="adapter-key", + payload={"x": 1}, + result={"ok": True}, + ) + replay = coordinator.remember_idempotency( + key="adapter-key", + payload={"x": 1}, + result={"ok": True}, + ) + + assert raised.value.code == "lease_conflict" + assert replay["replay"] is True + assert coordinator.status()["sessions"][0]["status"] == "running" + assert coordinator.watch()[-1]["event"] == "conflict.detected" + + +def _is_local_uri(uri: str) -> bool: + if not uri: + return False + host = urlparse(uri).hostname + return host in {"localhost", "127.0.0.1", "::1"} diff --git a/tests/test_core_purity.py b/tests/test_core_purity.py index 42f6e22..f375738 100644 --- a/tests/test_core_purity.py +++ b/tests/test_core_purity.py @@ -34,3 +34,13 @@ def test_first_party_runtime_packages_have_no_driver_or_llm_sdk() -> None: "cfg runtime packages outside adapters must not import DB drivers or LLM SDKs. Offenders:\n" + "\n".join(offenders) ) + + +def test_core_does_not_import_optional_plugins() -> None: + offenders: list[str] = [] + for path in (CFG / "core").rglob("*.py"): + text = path.read_text(encoding="utf-8") + for forbidden in ("cfg_impact", "cfg_agent"): + if forbidden in text: + offenders.append(f"{path.relative_to(CFG.parent)} imports {forbidden}") + assert offenders == [] diff --git a/tests/test_ui_server.py b/tests/test_ui_server.py index d46c5b6..bd07829 100644 --- a/tests/test_ui_server.py +++ b/tests/test_ui_server.py @@ -61,6 +61,16 @@ def test_ui_contains_recent_activity_history() -> None: assert "Select a recent entry" in UI_HTML +def test_ui_contains_agent_coordination_manager() -> None: + from cfg.ui.server import UI_HTML + + for marker in ('id="agents"', "/api/agent/state", "/api/agent/action", "Agent coordination"): + assert marker in UI_HTML + assert "end_session" in UI_HTML + assert "close_intent" in UI_HTML + assert "release" in UI_HTML + + class _busy_port: def __enter__(self) -> int: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) From a2e45c2a17962c47f991b70c4f06fde2aee143cc Mon Sep 17 00:00:00 2001 From: Mohammad Ausaf Date: Sat, 4 Jul 2026 01:28:15 +0530 Subject: [PATCH 2/2] Harden cfgit agent coordination races --- plugins/cfg_agent/cfg_agent/adapters/mongo.py | 153 ++++++++++++---- .../cfg_agent/cfg_agent/adapters/postgres.py | 74 +++++--- plugins/cfg_agent/cfg_agent/coordinator.py | 9 + plugins/cfg_agent/cfg_agent/state.py | 8 + tests/test_agent_coordination.py | 172 +++++++++++++++++- 5 files changed, 355 insertions(+), 61 deletions(-) diff --git a/plugins/cfg_agent/cfg_agent/adapters/mongo.py b/plugins/cfg_agent/cfg_agent/adapters/mongo.py index 1b74bdc..9ec840c 100644 --- a/plugins/cfg_agent/cfg_agent/adapters/mongo.py +++ b/plugins/cfg_agent/cfg_agent/adapters/mongo.py @@ -6,10 +6,11 @@ from cfg.core.config import ProjectConfig from cfg_agent.resources import parse_resource, resources_overlap -from cfg_agent.state import AgentStateError, IdempotencyResult, _iso, utcnow +from cfg_agent.state import AgentStateError, IdempotencyResult, _iso, _parse_time, utcnow try: # pragma: no cover - exercised when cfgit[mongo] is installed - from pymongo import ASCENDING, MongoClient + from pymongo import ASCENDING, ReturnDocument, MongoClient + from pymongo.errors import DuplicateKeyError, OperationFailure, PyMongoError except ModuleNotFoundError as exc: # pragma: no cover raise ModuleNotFoundError("install cfgit[mongo] to use MongoAgentStateAdapter") from exc @@ -69,31 +70,88 @@ def list_sessions(self, status: str | None = None) -> list[dict[str, Any]]: return self._list_state("session", status=status, sort_field="started_at") def acquire_lease(self, lease: dict[str, Any], *, now: datetime) -> dict[str, Any]: - with self.client.start_session() as session: - with session.start_transaction(): - self._touch_lock("leases", now, session=session) - self._expire_leases(now, session=session) - resource = parse_resource(lease["resource"]) - conflicts = [] - for existing in self._list_state("lease", status="active", session=session): - if existing.get("session_id") == lease["session_id"]: - continue - if resources_overlap(resource, existing["resource"]): - conflicts.append(existing) - if conflicts: + for attempt in range(3): + try: + with self.client.start_session() as session: + with session.start_transaction(): + self._touch_lock("leases", now, session=session) + self._expire_leases(now, session=session) + resource = parse_resource(lease["resource"]) + conflicts = [] + for existing in self._list_state("lease", status="active", session=session): + if existing.get("session_id") == lease["session_id"]: + continue + if resources_overlap(resource, existing["resource"]): + conflicts.append(existing) + if conflicts: + raise AgentStateError( + "lease_conflict", + "another active lease overlaps this resource", + {"leases": conflicts}, + ) + return self._insert_state("lease", lease["lease_id"], lease, session=session) + except DuplicateKeyError as exc: + existing = self._get_state("lease", lease["lease_id"]) + if _lease_matches(existing, lease): + return existing + raise AgentStateError( + "lease_state_conflict", + "lease id already exists with a different payload", + {"lease_id": lease["lease_id"]}, + ) from exc + except AgentStateError: + raise + except (OperationFailure, PyMongoError) as exc: + if _has_error_label(exc, "UnknownTransactionCommitResult"): + existing = self._get_state("lease", lease["lease_id"]) + if _lease_matches(existing, lease): + return existing raise AgentStateError( - "lease_conflict", - "another active lease overlaps this resource", - {"leases": conflicts}, - ) - return self._insert_state("lease", lease["lease_id"], lease, session=session) + "lease_commit_unknown", + "Mongo could not confirm whether the lease transaction committed", + {"lease_id": lease["lease_id"], "error": str(exc)}, + ) from exc + if attempt < 2 and _retryable_transaction_error(exc): + continue + raise AgentStateError( + "lease_transaction_failed", + "Mongo lease transaction failed", + {"error": str(exc)}, + ) from exc + raise AgentStateError("lease_transaction_failed", "Mongo lease transaction failed") def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict[str, Any]: - lease = self._require("lease", lease_id, "lease_not_found") + updated = self.state.find_one_and_update( + { + "env": self.env_name, + "kind": "lease", + "id": lease_id, + "status": "active", + "expires_at": {"$gt": _iso(now)}, + }, + {"$set": {"expires_at": _iso(now + timedelta(seconds=ttl_seconds))}}, + return_document=ReturnDocument.AFTER, + ) + if updated: + return _strip(updated) + lease = self._get_state("lease", lease_id) + if lease is None: + raise AgentStateError("lease_not_found", f"{lease_id} was not found", {"id": lease_id}) if lease.get("status") != "active": raise AgentStateError("lease_not_active", "lease is not active", {"lease_id": lease_id}) - lease["expires_at"] = _iso(now + timedelta(seconds=ttl_seconds)) - return self._put_state("lease", lease_id, lease) + if _parse_time(lease["expires_at"]) <= now: + self.state.update_one( + { + "env": self.env_name, + "kind": "lease", + "id": lease_id, + "status": "active", + "expires_at": {"$lte": _iso(now)}, + }, + {"$set": {"status": "expired"}}, + ) + raise AgentStateError("lease_expired", "lease has expired", {"lease_id": lease_id}) + raise AgentStateError("lease_renew_conflict", "lease changed during renew", {"lease_id": lease_id}) def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: lease = self._require("lease", lease_id, "lease_not_found") @@ -102,6 +160,9 @@ def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: lease["released_at"] = _iso(now) return self._put_state("lease", lease_id, lease) + def get_lease(self, lease_id: str) -> dict[str, Any] | None: + return self._get_state("lease", lease_id) + def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: self._expire_leases(now or utcnow()) return self._list_state("lease", status="active" if active_only else None, sort_field="created_at") @@ -122,8 +183,23 @@ def list_intents(self, status: str | None = None) -> list[dict[str, Any]]: return self._list_state("intent", status=status, sort_field="created_at") def remember_idempotency(self, key: str, payload_hash: str, result: dict[str, Any], now: datetime) -> IdempotencyResult: - existing = self._get_state("idempotency", key) - if existing: + doc = { + "kind": "idempotency", + "idempotency_key": key, + "payload_hash": payload_hash, + "result": deepcopy(result), + "created_at": _iso(now), + } + try: + self._insert_state("idempotency", key, doc) + except DuplicateKeyError: + existing = self._get_state("idempotency", key) + if existing is None: + raise AgentStateError( + "idempotency_race", + "idempotency key was concurrently inserted but could not be read", + {"key": key}, + ) from None if existing.get("payload_hash") != payload_hash: raise AgentStateError( "idempotency_conflict", @@ -131,17 +207,6 @@ def remember_idempotency(self, key: str, payload_hash: str, result: dict[str, An {"key": key}, ) return IdempotencyResult(replay=True, result=deepcopy(existing.get("result"))) - self._insert_state( - "idempotency", - key, - { - "kind": "idempotency", - "idempotency_key": key, - "payload_hash": payload_hash, - "result": deepcopy(result), - "created_at": _iso(now), - }, - ) return IdempotencyResult(replay=False, result=None) def get_idempotency(self, key: str) -> dict[str, Any] | None: @@ -253,3 +318,21 @@ def _touch_lock(self, lock_id: str, now: datetime, *, session: Any | None = None def _strip(row: dict[str, Any]) -> dict[str, Any]: return {key: value for key, value in row.items() if key not in {"_id", "env", "id"}} + + +def _lease_matches(existing: dict[str, Any] | None, lease: dict[str, Any]) -> bool: + if existing is None: + return False + fields = ("lease_id", "session_id", "resource", "collection", "record_id", "path", "scope", "status") + return all(existing.get(field) == lease.get(field) for field in fields) + + +def _has_error_label(exc: PyMongoError, label: str) -> bool: + has_label = getattr(exc, "has_error_label", None) + return bool(callable(has_label) and has_label(label)) + + +def _retryable_transaction_error(exc: PyMongoError) -> bool: + if _has_error_label(exc, "TransientTransactionError"): + return True + return "WriteConflict" in str(exc) diff --git a/plugins/cfg_agent/cfg_agent/adapters/postgres.py b/plugins/cfg_agent/cfg_agent/adapters/postgres.py index b572219..f653378 100644 --- a/plugins/cfg_agent/cfg_agent/adapters/postgres.py +++ b/plugins/cfg_agent/cfg_agent/adapters/postgres.py @@ -7,7 +7,7 @@ from cfg.core.config import ProjectConfig from cfg_agent.resources import parse_resource, resources_overlap -from cfg_agent.state import AgentStateError, IdempotencyResult, _iso, utcnow +from cfg_agent.state import AgentStateError, IdempotencyResult, _iso, _parse_time, utcnow try: # pragma: no cover - exercised when cfgit[postgres] is installed import psycopg @@ -129,6 +129,10 @@ def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict lease = self._require("lease", lease_id, "lease_not_found") if lease.get("status") != "active": raise AgentStateError("lease_not_active", "lease is not active", {"lease_id": lease_id}) + if _parse_time(lease["expires_at"]) <= now: + lease["status"] = "expired" + self._put_state("lease", lease_id, lease) + raise AgentStateError("lease_expired", "lease has expired", {"lease_id": lease_id}) lease["expires_at"] = _iso(now + timedelta(seconds=ttl_seconds)) return self._put_state("lease", lease_id, lease) @@ -139,6 +143,9 @@ def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: lease["released_at"] = _iso(now) return self._put_state("lease", lease_id, lease) + def get_lease(self, lease_id: str) -> dict[str, Any] | None: + return self._get_state("lease", lease_id) + def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: self._expire_leases(now or utcnow()) return self._list_state("lease", status="active" if active_only else None, sort_field="created_at") @@ -159,27 +166,30 @@ def list_intents(self, status: str | None = None) -> list[dict[str, Any]]: return self._list_state("intent", status=status, sort_field="created_at") def remember_idempotency(self, key: str, payload_hash: str, result: dict[str, Any], now: datetime) -> IdempotencyResult: + doc = { + "kind": "idempotency", + "idempotency_key": key, + "payload_hash": payload_hash, + "result": deepcopy(result), + "created_at": _iso(now), + } + inserted = self._insert_state_once("idempotency", key, doc) + if inserted: + return IdempotencyResult(replay=False, result=None) existing = self._get_state("idempotency", key) - if existing: - if existing.get("payload_hash") != payload_hash: - raise AgentStateError( - "idempotency_conflict", - "idempotency key was already used with a different payload", - {"key": key}, - ) - return IdempotencyResult(replay=True, result=deepcopy(existing.get("result"))) - self._insert_state( - "idempotency", - key, - { - "kind": "idempotency", - "idempotency_key": key, - "payload_hash": payload_hash, - "result": deepcopy(result), - "created_at": _iso(now), - }, - ) - return IdempotencyResult(replay=False, result=None) + if existing is None: + raise AgentStateError( + "idempotency_race", + "idempotency key was concurrently inserted but could not be read", + {"key": key}, + ) + if existing.get("payload_hash") != payload_hash: + raise AgentStateError( + "idempotency_conflict", + "idempotency key was already used with a different payload", + {"key": key}, + ) + return IdempotencyResult(replay=True, result=deepcopy(existing.get("result"))) def get_idempotency(self, key: str) -> dict[str, Any] | None: return self._get_state("idempotency", key) @@ -234,9 +244,24 @@ def list_events(self, *, since_event_id: str | None = None, limit: int = 100) -> return deepcopy(rows[-limit:]) def _insert_state(self, kind: str, item_id: str, doc: dict[str, Any]) -> dict[str, Any]: - if self._get_state(kind, item_id) is not None: + if not self._insert_state_once(kind, item_id, doc): raise AgentStateError("state_conflict", f"{kind} already exists", {"id": item_id}) - return self._put_state(kind, item_id, doc) + return deepcopy(doc) + + def _insert_state_once(self, kind: str, item_id: str, doc: dict[str, Any]) -> bool: + item = deepcopy(doc) + status = item.get("status") + resource = item.get("resource") + with self.conn.cursor() as cur: + cur.execute( + f""" + INSERT INTO {self.state_table} (env, kind, id, status, resource, updated_at, doc) + VALUES (%s, %s, %s, %s, %s, now(), %s) + ON CONFLICT (env, kind, id) DO NOTHING + """, + [self.env_name, kind, item_id, status, resource, Jsonb(_jsonable(item))], + ) + return cur.rowcount == 1 def _put_state(self, kind: str, item_id: str, doc: dict[str, Any]) -> dict[str, Any]: item = deepcopy(doc) @@ -287,9 +312,8 @@ def _list_state(self, kind: str, *, status: str | None = None, sort_field: str = return [dict(row["doc"]) for row in cur.fetchall()] def _expire_leases(self, now: datetime) -> None: - now_iso = _iso(now) for lease in self._list_state("lease", status="active"): - if str(lease.get("expires_at") or "") <= now_iso: + if _parse_time(lease["expires_at"]) <= now: lease["status"] = "expired" self._put_state("lease", lease["lease_id"], lease) diff --git a/plugins/cfg_agent/cfg_agent/coordinator.py b/plugins/cfg_agent/cfg_agent/coordinator.py index c326a36..f0c73e7 100644 --- a/plugins/cfg_agent/cfg_agent/coordinator.py +++ b/plugins/cfg_agent/cfg_agent/coordinator.py @@ -127,6 +127,15 @@ def claim( def release(self, *, session_id: str, lease_id: str) -> dict[str, Any]: session = self._require_running_session(session_id) + lease_before_release = self.adapter.get_lease(lease_id) + if lease_before_release is None: + raise AgentStateError("lease_not_found", f"{lease_id} was not found", {"id": lease_id}) + if lease_before_release.get("session_id") != session_id: + raise AgentStateError( + "lease_not_owned", + "lease belongs to a different session", + {"lease_id": lease_id, "owner_session_id": lease_before_release.get("session_id")}, + ) lease = self.adapter.release_lease(lease_id, now=utcnow()) self._event( "lease.released", diff --git a/plugins/cfg_agent/cfg_agent/state.py b/plugins/cfg_agent/cfg_agent/state.py index b024e97..091f57d 100644 --- a/plugins/cfg_agent/cfg_agent/state.py +++ b/plugins/cfg_agent/cfg_agent/state.py @@ -35,6 +35,7 @@ def list_sessions(self, status: str | None = None) -> list[dict[str, Any]]: ... def acquire_lease(self, lease: dict[str, Any], *, now: datetime) -> dict[str, Any]: ... def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict[str, Any]: ... def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: ... + def get_lease(self, lease_id: str) -> dict[str, Any] | None: ... def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: ... def open_intent(self, intent: dict[str, Any]) -> dict[str, Any]: ... def close_intent(self, intent_id: str, status: str, now: datetime) -> dict[str, Any]: ... @@ -125,6 +126,9 @@ def renew_lease(self, lease_id: str, *, ttl_seconds: int, now: datetime) -> dict lease = self._require(self.leases, lease_id, "lease_not_found") if lease.get("status") != "active": raise AgentStateError("lease_not_active", "lease is not active", {"lease_id": lease_id}) + if _parse_time(lease["expires_at"]) <= now: + lease["status"] = "expired" + raise AgentStateError("lease_expired", "lease has expired", {"lease_id": lease_id}) lease["expires_at"] = _iso(now + timedelta(seconds=ttl_seconds)) return deepcopy(lease) @@ -136,6 +140,10 @@ def release_lease(self, lease_id: str, *, now: datetime) -> dict[str, Any]: lease["released_at"] = _iso(now) return deepcopy(lease) + def get_lease(self, lease_id: str) -> dict[str, Any] | None: + with self._lock: + return deepcopy(self.leases.get(lease_id)) + def list_leases(self, *, active_only: bool = True, now: datetime | None = None) -> list[dict[str, Any]]: with self._lock: self._expire_leases(now or utcnow()) diff --git a/tests/test_agent_coordination.py b/tests/test_agent_coordination.py index c7d1928..7e07760 100644 --- a/tests/test_agent_coordination.py +++ b/tests/test_agent_coordination.py @@ -3,7 +3,7 @@ import os import pathlib import sys -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from uuid import uuid4 from urllib.parse import urlparse @@ -71,6 +71,124 @@ def test_non_overlapping_field_claims_can_run_in_parallel() -> None: assert coordinator.conflicts() == [] +def test_release_requires_lease_owner() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + coordinator = AgentCoordinator(InMemoryAgentStateAdapter()) + owner = coordinator.start_session(task="instructions", agent_id="agent.owner") + other = coordinator.start_session(task="other", agent_id="agent.other") + + lease = coordinator.claim( + session_id=owner["session_id"], + resource="agent_configs:refund_resolution:/instructions", + reason="copy edit", + ) + + with pytest.raises(AgentStateError) as raised: + coordinator.release(session_id=other["session_id"], lease_id=lease["lease_id"]) + + assert raised.value.code == "lease_not_owned" + assert coordinator.adapter.get_lease(lease["lease_id"])["status"] == "active" + + +def test_renew_expired_lease_is_blocked() -> None: + from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter + + adapter = InMemoryAgentStateAdapter() + coordinator = AgentCoordinator(adapter) + session = coordinator.start_session(task="short lease", agent_id="agent.a") + + lease = coordinator.claim( + session_id=session["session_id"], + resource="agent_configs:refund_resolution:/instructions", + ttl_seconds=1, + ) + + with pytest.raises(AgentStateError) as raised: + adapter.renew_lease( + lease["lease_id"], + ttl_seconds=10, + now=datetime.now(timezone.utc) + timedelta(seconds=2), + ) + + assert raised.value.code == "lease_expired" + assert adapter.get_lease(lease["lease_id"])["status"] == "expired" + + +def test_mongo_renew_lease_uses_conditional_update_without_upsert() -> None: + pytest.importorskip("pymongo") + from cfg_agent.adapters.mongo import MongoAgentStateAdapter + + now = datetime(2026, 7, 4, tzinfo=timezone.utc) + fake_state = _FakeMongoAgentState( + { + "env": "dev", + "kind": "lease", + "id": "lea_1", + "lease_id": "lea_1", + "session_id": "ses_1", + "resource": "demo:alpha:/value", + "status": "active", + "expires_at": "2026-07-04T00:10:00Z", + } + ) + adapter = object.__new__(MongoAgentStateAdapter) + adapter.env_name = "dev" + adapter.state = fake_state + + renewed = adapter.renew_lease("lea_1", ttl_seconds=60, now=now) + + assert renewed["status"] == "active" + assert fake_state.find_one_and_update_calls[0]["query"] == { + "env": "dev", + "kind": "lease", + "id": "lea_1", + "status": "active", + "expires_at": {"$gt": "2026-07-04T00:00:00Z"}, + } + assert "upsert" not in fake_state.find_one_and_update_calls[0]["kwargs"] + assert fake_state.replace_one_calls == [] + + +def test_mongo_renew_lease_marks_expired_without_replacing() -> None: + pytest.importorskip("pymongo") + from cfg_agent import AgentStateError + from cfg_agent.adapters.mongo import MongoAgentStateAdapter + + now = datetime(2026, 7, 4, tzinfo=timezone.utc) + fake_state = _FakeMongoAgentState( + { + "env": "dev", + "kind": "lease", + "id": "lea_1", + "lease_id": "lea_1", + "session_id": "ses_1", + "resource": "demo:alpha:/value", + "status": "active", + "expires_at": "2026-07-03T23:59:59Z", + } + ) + adapter = object.__new__(MongoAgentStateAdapter) + adapter.env_name = "dev" + adapter.state = fake_state + + with pytest.raises(AgentStateError) as raised: + adapter.renew_lease("lea_1", ttl_seconds=60, now=now) + + assert raised.value.code == "lease_expired" + assert fake_state.doc["status"] == "expired" + assert fake_state.replace_one_calls == [] + + +def test_mongo_unknown_commit_is_not_retried_as_whole_transaction() -> None: + pytest.importorskip("pymongo") + from cfg_agent.adapters.mongo import _retryable_transaction_error + + assert not _retryable_transaction_error(_MongoLabelError("UnknownTransactionCommitResult")) + assert _retryable_transaction_error(_MongoLabelError("TransientTransactionError")) + assert _retryable_transaction_error(_MongoLabelError("WriteConflict")) + + def test_overlapping_claim_creates_structured_conflict() -> None: from cfg_agent import AgentCoordinator, AgentStateError, InMemoryAgentStateAdapter @@ -901,6 +1019,58 @@ def _exercise_agent_state_adapter(adapter) -> None: assert coordinator.watch()[-1]["event"] == "conflict.detected" +class _FakeMongoAgentState: + def __init__(self, doc: dict): + self.doc = dict(doc) + self.find_one_and_update_calls: list[dict] = [] + self.replace_one_calls: list[dict] = [] + + def find_one_and_update(self, query: dict, update: dict, **kwargs): + self.find_one_and_update_calls.append({"query": query, "update": update, "kwargs": kwargs}) + if self._matches(query): + self.doc.update(update.get("$set", {})) + return dict(self.doc) + return None + + def find_one(self, query: dict, session=None): + if self._matches(query): + return dict(self.doc) + return None + + def update_one(self, query: dict, update: dict, session=None): + if self._matches(query): + self.doc.update(update.get("$set", {})) + return None + + def replace_one(self, *args, **kwargs): + self.replace_one_calls.append({"args": args, "kwargs": kwargs}) + return None + + def _matches(self, query: dict) -> bool: + for key, expected in query.items(): + actual = self.doc.get(key) + if isinstance(expected, dict): + gt = expected.get("$gt") + if gt is not None and not (actual > gt): + return False + lte = expected.get("$lte") + if lte is not None and not (actual <= lte): + return False + continue + if actual != expected: + return False + return True + + +class _MongoLabelError(Exception): + def __init__(self, label: str): + super().__init__(label) + self.label = label + + def has_error_label(self, label: str) -> bool: + return self.label == label + + def _is_local_uri(uri: str) -> bool: if not uri: return False