diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 2475bf0d..0b11807a 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -33,47 +33,77 @@ jobs: claude-review-with-tracking: runs-on: ubuntu-latest - # Only run for trusted authors or when explicitly mentioned by them + # Run on PRs or when explicitly triggered via comments if: | - ( - github.event_name == 'pull_request_target' && - ( - github.event.pull_request.author_association == 'OWNER' || - github.event.pull_request.author_association == 'MEMBER' || - github.event.pull_request.author_association == 'COLLABORATOR' - ) - ) || + github.event_name == 'pull_request_target' || ( (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') && - contains(github.event.comment.body, '@claude') && - ( - github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR' - ) + contains(github.event.comment.body, '@claude') ) || ( github.event_name == 'pull_request_review' && - contains(github.event.review.body, '@claude') && - ( - github.event.review.author_association == 'OWNER' || - github.event.review.author_association == 'MEMBER' || - github.event.review.author_association == 'COLLABORATOR' - ) + contains(github.event.review.body, '@claude') ) steps: + # Checkout PR Branch (for comments) + - name: Resolve PR Ref + id: resolve-pr-ref + if: github.event_name == 'issue_comment' && github.event.issue.pull_request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=${{ github.event.issue.number }} + echo "Resolving HEAD SHA and Repo for PR #$PR_NUMBER..." + + # Get SHA and Repo Name using gh + # Note: Using awk implies fields are space-separated and don't contain spaces themselves, + # which is safe for git SHAs and "owner/repo" strings. + # We use a conditional template to handle cases where the head repository (fork) might be deleted/null. + RESOLVED=$(gh pr view $PR_NUMBER --repo ${{ github.repository }} --json headRefOid,headRepository --template '{{.headRefOid}} {{if .headRepository}}{{.headRepository.owner.login}}/{{.headRepository.name}}{{else}}MISSING{{end}}') + + PR_SHA=$(echo "$RESOLVED" | awk '{print $1}') + PR_REPO=$(echo "$RESOLVED" | awk '{print $2}') + + echo "Resolved SHA: $PR_SHA" + echo "Resolved Repo: $PR_REPO" + + # Check for valid repository format (owner/repo) + if [ "$PR_REPO" = "MISSING" ] || [[ "$PR_REPO" != *"/"* ]]; then + echo "Warning: Could not resolve source repository (fork might be deleted). Falling back to base repository." + PR_REPO="${{ github.repository }}" + fi + + echo "pr_sha=$PR_SHA" >> $GITHUB_OUTPUT + echo "pr_repo=$PR_REPO" >> $GITHUB_OUTPUT + # Checkout the repository at the appropriate commit for review - name: Checkout repository uses: actions/checkout@v6 with: + # Checkout the PR source repository (fork) to ensure branches exist for the action + repository: ${{ steps.resolve-pr-ref.outputs.pr_repo || github.event.pull_request.head.repo.full_name || github.repository }} # Use PR head SHA for pull_request_target to review the actual PR code # For comment events, this will default to the base branch (PR context is inferred by Claude action) - ref: ${{ github.event.pull_request.head.sha || github.sha }} - fetch-depth: 1 + ref: ${{ steps.resolve-pr-ref.outputs.pr_sha || github.event.pull_request.head.sha || github.sha }} + fetch-depth: 20 + + # Fix git ref access for claude-code-action PR branch checkout + # The action internally runs: git fetch origin pull/N/head:pr-N + # This can fail for two reasons: + # 1. Under git protocol v2, refs/pull/* may not be discoverable if not in + # the configured fetch refspec (causes "couldn't find remote ref") + # 2. If a local branch pr-N already exists and is checked out, git refuses + # to fetch into it (causes "refusing to fetch into branch") + # Adding refs/pull/*/head to the fetch refspec solves issue 1, and we avoid + # creating local branches to prevent issue 2. + - name: Configure git for PR ref access + run: | + git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pull/*/head' # Invoke Claude to perform an automated PR review with progress tracking - - name: PR Review with Progress Tracking + - name: Run Claude Code Review + id: claude-review uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/CLAUDE.md b/CLAUDE.md index 63509476..4f4ee12c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Repository Is -git-internal is a high-performance Rust library for encoding/decoding Git internal objects and Pack files. It supports large monorepo-scale repositories with delta compression, multi-pack indexing, streaming I/O, and both sync/async APIs. +git-internal is a high-performance Rust library for encoding/decoding Git internal objects, Pack files, and AI-assisted development objects. It supports large monorepo-scale repositories with delta compression, multi-pack indexing, streaming I/O, and both sync/async APIs. Beyond the standard Git object model (Blob, Tree, Commit, Tag), it provides a structured AI object model (Intent, Plan, Task, Run, PatchSet, Evidence, Decision, etc.) that captures the full lifecycle of AI-driven code changes. ## Build & Test Commands @@ -19,13 +19,21 @@ cargo test # Run specific test cargo test -- --nocapture # Show output # Lint & Format -cargo fmt # Format code +cargo +nightly fmt # Format code (requires nightly) +cargo +nightly fmt --check # Check formatting without modifying cargo clippy # Lint (treat warnings as errors for new code) # Check all targets compile cargo build --all-targets ``` +## Git Commands + +```bash +git commit -a -s -S -m"" # Commit +git push --force +``` + ## Architecture Overview ``` @@ -36,6 +44,12 @@ internal/pack (encode/decode/waitlist/cache/idx) ⇅ internal/object/index/metadata ⇅ delta / zstdelta / diff +internal/object + ├── Standard: blob, tree, commit, tag, note + ├── AI objects: intent, plan, task, run, patchset, + │ evidence, decision, provenance, tool, context, pipeline + └── Shared: types (Header, ActorRef, ObjectType), integrity, signature + hash.rs / utils.rs / errors.rs (shared infrastructure) ``` @@ -43,10 +57,74 @@ hash.rs / utils.rs / errors.rs (shared infrastructure) **Protocol layer**: `protocol/*` - drives info-refs/upload-pack/receive-pack via pkt-line, uses app-provided `RepositoryAccess` and `AuthenticationService` traits. -**Object model**: `internal/object` - Blob/Tree/Commit/Tag/Note parsing/serialization with `ObjectTrait`. +**Object model**: `internal/object` - standard Git objects (Blob/Tree/Commit/Tag/Note) and AI objects, all implementing `ObjectTrait` for unified serialization. **Delta/compression**: `delta/` and `zstdelta/` - delta encoding/decoding, zstd dictionary compression. +## AI Object Model + +The AI object model lives in `src/internal/object/` alongside standard Git objects. All AI objects implement `ObjectTrait`, share a common `Header` (UUID v7, timestamps, creator `ActorRef`), and are serialized as JSON. + +### End-to-End Flow + +``` + ① User input + ▼ + ② Intent (Draft → Active → Completed) + ▼ + ③ Plan (steps + ContextPipeline) + ▼ + ④ Task (constraints + acceptance criteria) + ▼ + ⑤ Run (baseline commit + environment) + ├── Provenance (LLM config, 1:1) + ├── ContextSnapshot (static context, optional) + ├── ⑥ ToolInvocation (action log, 1:N) + ├── ⑦ PatchSet (candidate diff) + ├── ⑧ Evidence (test/lint/build, 1:N) + ▼ + ⑨ Decision (commit / retry / abandon / rollback) + ▼ + ⑩ Intent (Completed, commit recorded) +``` + +### AI Object Files + +| File | Object(s) | Role | +|------|-----------|------| +| `intent.rs` | `Intent`, `IntentStatus` | User prompt + AI interpretation; workflow entry/exit | +| `plan.rs` | `Plan`, `PlanStep`, `StepStatus` | Ordered steps from an Intent; revision chain via `previous` | +| `task.rs` | `Task`, `TaskStatus`, `GoalType` | Unit of work with constraints and acceptance criteria | +| `run.rs` | `Run`, `RunStatus`, `Environment` | Single execution attempt; accumulates artifacts | +| `tool.rs` | `ToolInvocation`, `IoFootprint` | Per-tool-call action log with file I/O tracking | +| `patchset.rs` | `PatchSet`, `PatchSetStatus` | Candidate unified diff with touched-file summary | +| `evidence.rs` | `Evidence`, `EvidenceKind` | Validation output (test, lint, build) | +| `decision.rs` | `Decision`, `Verdict` | Terminal verdict on a Run | +| `provenance.rs` | `Provenance`, `TokenUsage` | LLM model config and token metrics | +| `context.rs` | `ContextSnapshot`, `ContextItem` | Static file/URL/snippet capture at Run start | +| `pipeline.rs` | `ContextPipeline`, `ContextFrame` | Dynamic sliding-window context during planning | + +### Shared Types (`types.rs`) + +- `Header` — common header for all AI objects (UUID v7 `object_id`, `object_type`, `created_at`, `updated_at`, `created_by`) +- `ActorRef` — actor identity with kind (`agent`, `human`, `system`, `tool`) and name +- `ArtifactRef` — reference to an external artifact (kind + locator) +- `ObjectType` — enum covering both standard Git types and AI types +- `IntegrityHash` — SHA-256 content hash for commit references in AI objects (in `integrity.rs`) + +### Key Patterns + +- **Append-only history**: `Intent.statuses`, `PlanStep.statuses`, `Task.runs`, `Run.patchsets` — append-only vectors that preserve full history. +- **Snapshot references**: `Run.plan` records the Plan version at execution time and never changes; `Intent.plan` always points to the latest revision. +- **Revision chains**: `Plan.previous` links to the prior Plan version, forming an immutable chain. +- **Recursive decomposition**: `PlanStep.task` can reference a sub-Task with its own Run/Plan lifecycle; `Task.parent` provides the reverse link. +- **Context separation**: `ContextSnapshot` (static, at Run start) vs `ContextPipeline` (dynamic, accumulated during planning with frame eviction). +- **Serde conventions**: `#[serde(default)]` + `skip_serializing_if` on optional/empty fields; `rename_all = "snake_case"` on enums; `#[serde(alias = "...")]` for backward-compatible renames. + +### Documentation + +Full AI object lifecycle, field-level docs, and usage examples: `docs/ai.md`. + ## Key Data Flows **Pack Decode**: `Pack::decode(reader, callback)` or `Pack::decode_stream(stream, sender)` for async @@ -59,14 +137,17 @@ hash.rs / utils.rs / errors.rs (shared infrastructure) - upload-pack: parse want/have → `PackGenerator` builds pack stream - receive-pack: parse commands → decode pack → store via `RepositoryAccess` +**AI Object Persistence**: AI objects are stored as content-addressed JSON blobs in the Git object database using their own `ObjectType` discriminator. They are excluded from pack encode/decode paths (rejected at the pack layer boundary). + ## Coding Conventions - **Language**: Rust Edition 2024, async/await with tokio, tracing for observability - **Errors**: `thiserror` for library errors, `anyhow` for binaries/tests -- **Style**: rustfmt defaults, clippy warnings as errors for new code +- **Style**: rustfmt defaults (nightly), clippy warnings as errors for new code - **Safety**: Avoid `unwrap()`/`expect()` in library code; return `Result<_, _>` - **Performance**: Use iterators, streaming I/O, bounded allocations in hot paths - **FFI/unsafe**: Only when required, with `// SAFETY:` comment and tests +- **AI objects**: JSON serialization via serde; `ObjectTrait` implementation with `from_bytes`/`to_data`/`get_type`/`get_size`; doc comments follow the pattern: module-level Position in Lifecycle diagram, Relationships table, Purpose section, field-level docs ## Hash Algorithm @@ -77,6 +158,8 @@ use git_internal::hash::{set_hash_kind, HashKind}; set_hash_kind(HashKind::Sha1); // or HashKind::Sha256 ``` +AI objects use `IntegrityHash` (always SHA-256) for commit references, independent of the repository's hash algorithm. + ## Concurrency Model - **ThreadPool**: parallel inflate and delta rebuild during pack decode @@ -87,13 +170,34 @@ set_hash_kind(HashKind::Sha1); // or HashKind::Sha256 ## Key Types to Know +**Standard Git**: - `Pack` - main pack decoder/encoder entry point - `Entry` / `EntryMeta` - decoded object with metadata (offset, CRC, path) - `ObjectHash` - SHA-1 or SHA-256 object identifier -- `ObjectType` - Blob/Tree/Commit/Tag enum +- `ObjectType` - Blob/Tree/Commit/Tag + AI type variants - `RepositoryAccess` - trait for storage backend integration - `GitProtocol` / `SmartProtocol` - protocol handling traits +**AI Objects**: +- `Intent` - workflow entry point; user prompt + AI interpretation +- `Plan` / `PlanStep` - planning artifact with ordered steps +- `Task` - stable work identity with acceptance criteria +- `Run` - execution attempt; records baseline commit and environment +- `PatchSet` - candidate diff artifact +- `Evidence` - validation result (test/lint/build) +- `Decision` - terminal verdict (Commit/Retry/Abandon/Rollback) +- `Provenance` - LLM configuration and token usage +- `ContextSnapshot` / `ContextPipeline` - static and dynamic context +- `ToolInvocation` - per-tool-call action log +- `Header` / `ActorRef` - shared metadata types + ## Test Data -Real pack files in `tests/data/packs/` (e.g., `small-sha1.pack`). Use for decode/encode roundtrip testing. +Real pack files in `tests/data/packs/` (e.g., `small-sha1.pack`). Use for decode/encode roundtrip testing. AI object unit tests are inline in each module file. + +## Documentation + +- `docs/ARCHITECTURE.md` - overall library architecture +- `docs/GIT_OBJECTS.md` - standard Git object format reference +- `docs/GIT_PROTOCOL_GUIDE.md` - Git smart protocol guide +- `docs/ai.md` - AI object model: lifecycle, fields, and usage examples diff --git a/Cargo.toml b/Cargo.toml index 8ddaf5e8..f3dff087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,11 +2,19 @@ name = "git-internal" version = "0.6.0" edition = "2024" +authors = ["Eli Ma "] +description = "High-performance Rust library for Git internal objects, Pack files, and AI-assisted development objects (Intent, Plan, Task, Run, Evidence, Decision) with delta compression, streaming I/O, and smart protocol support." +keywords = ["git", "pack", "delta", "ai-objects", "monorepo"] +categories = ["development-tools", "encoding", "parser-implementations"] +documentation = "https://libra.tools/docs/internal" +readme = "README.md" +homepage = "https://libra.tools" +repository = "https://github.com/web3infra-foundation/libra" license = "MIT" -description = "Git-Internal is a high-performance Rust library for encoding and decoding Git internal objects and Pack files." -exclude = ["tests"] +exclude = ["tests", ".github"] -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[badges] +maintenance = { status = "actively-developed" } [dependencies] bstr = "1.12.1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9c2daf2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web3 Infrastructure Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 280a7480..5deb2099 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ ## Git Internal Module -Git-Internal is a high-performance Rust library for encoding and decoding Git internal objects and Pack files. It provides comprehensive support for Git's internal object storage format with advanced features like delta compression, memory management, and concurrent processing. +Git-Internal is a high-performance Rust library for Git internal objects, Pack files, and AI-assisted development workflows. It provides comprehensive support for Git's internal object storage format with delta compression, memory management, concurrent processing, and a structured AI object model that captures the full lifecycle of AI-driven code changes — from user intent through planning, execution, validation, and final decision. ## Overview -This module is designed to handle Git internal objects and Pack files efficiently, supporting both reading and writing operations with optimized memory usage and multi-threaded processing capabilities. The library implements the complete Git Pack format specification with additional optimizations for large-scale Git operations. +This library handles Git internal objects and Pack files efficiently, supporting both reading and writing with optimized memory usage and multi-threaded processing. Beyond the standard Git object model (Blob, Tree, Commit, Tag), it introduces a suite of **AI objects** (Intent, Plan, Task, Run, PatchSet, Evidence, Decision, and more) that record and audit every step of an AI agent's interaction with a codebase. These AI objects are stored as content-addressed JSON blobs in the Git object database, enabling reproducibility, auditability, and provenance tracking for AI-generated code changes. ## Quickstart @@ -32,11 +32,12 @@ fn main() -> Result<(), Box> { ## Modules at a glance - `hash.rs`: object IDs and hash algorithm selection (thread-local), set once by your app. -- `internal/object` / `internal/index` / `internal/metadata`: object parse/serialize, .git/index IO, path/offset/CRC metadata. +- `internal/object`: object parse/serialize — standard Git objects (Blob, Tree, Commit, Tag) and AI objects (Intent, Plan, Task, Run, etc.). +- `internal/index` / `internal/metadata`: .git/index IO, path/offset/CRC metadata. - `delta` / `zstdelta` / `diff.rs`: delta compression, zstd dictionary delta, line-level diff. - `internal/pack`: pack decode/encode, waitlist, cache, idx building. - `protocol/*`: smart protocol + HTTP/SSH adapters, wrapping info-refs/upload-pack/receive-pack. -- Docs: [docs/ARCHITECTURE.md (architecture)](docs/ARCHITECTURE.md), [docs/GIT_OBJECTS.md (objects)](docs/GIT_OBJECTS.md), [docs/GIT_PROTOCOL_GUIDE.md (protocol)](docs/GIT_PROTOCOL_GUIDE.md). +- Docs: [docs/ARCHITECTURE.md (architecture)](docs/ARCHITECTURE.md), [docs/GIT_OBJECTS.md (objects)](docs/GIT_OBJECTS.md), [docs/GIT_PROTOCOL_GUIDE.md (protocol)](docs/GIT_PROTOCOL_GUIDE.md), [docs/ai.md (AI objects)](docs/ai.md). ## Key Features @@ -66,6 +67,52 @@ fn main() -> Result<(), Box> { - Memory-efficient handling of large pack files - Support for network streams and file streams +### 5. AI Object Model + +A structured object graph that captures the full lifecycle of AI-assisted code changes: + +``` + ① User input + │ + ▼ + ② Intent (Draft → Active → Completed) + │ + ▼ + ③ Plan (steps + context pipeline) + │ + ▼ + ④ Task (unit of work with acceptance criteria) + │ + ▼ + ⑤ Run (execution attempt: patching → validating) + │ + ├── ⑥ ToolInvocation (action log) + ├── ⑦ PatchSet (candidate diff) + ├── ⑧ Evidence (test/lint/build results) + │ + ▼ + ⑨ Decision (commit / retry / abandon / rollback) + │ + ▼ + ⑩ Intent (Completed, commit hash recorded) +``` + +| Object | Role | +|--------|------| +| **Intent** | Captures user prompt and AI interpretation; entry/exit point of the workflow | +| **Plan** | Ordered sequence of steps derived from an Intent; supports revision chains | +| **Task** | Stable work identity with constraints and acceptance criteria | +| **Run** | Single execution attempt of a Task; accumulates artifacts | +| **ToolInvocation** | Records each tool call (file read, shell command, etc.) | +| **PatchSet** | Candidate code diff (unified format) relative to a baseline commit | +| **Evidence** | Validation result (test pass/fail, lint output, build log) | +| **Decision** | Terminal verdict on a Run (commit, retry, abandon, rollback) | +| **Provenance** | LLM configuration and token usage metadata for a Run | +| **ContextSnapshot** | Static capture of files/URLs/snippets at Run start | +| **ContextPipeline** | Dynamic sliding-window context accumulated during planning | + +All AI objects share a common `Header` (UUID, timestamps, creator) and are serialized as JSON. See [docs/ai.md](docs/ai.md) for the full lifecycle, field-level documentation, and usage examples. + ## Core Algorithms ### Pack Decoding Algorithm diff --git a/docs/Analytical Report on SHA-256 Hash Computation.md b/docs/Analytical Report on SHA-256 Hash Computation.md index c1e0d147..fe639892 100644 --- a/docs/Analytical Report on SHA-256 Hash Computation.md +++ b/docs/Analytical Report on SHA-256 Hash Computation.md @@ -1,4 +1,5 @@ # Background and Problem Overview + Since its inception, Git has relied on **SHA-1** as its core hash algorithm for naming all objects (commits, trees, blobs, and tags). The 160-bit hash values are represented as 40-character hexadecimal strings and are deeply embedded in repository storage, index structures, network protocols, and a wide range of tooling. However, practical collision attacks against **SHA-1** have already been demonstrated, and its security no longer meets long-term requirements. The Git community has outlined a migration path from **SHA-1** to **SHA-256** in the [_hash-function-transition_](https://git-scm.com/docs/hash-function-transition) document, and introduced experimental features such as `--object-format=sha256` in the 2.x series, with the plan to gradually adopt **SHA-256** as the default object format for newly created repositories in Git 3.0. diff --git a/docs/ai.md b/docs/ai.md new file mode 100644 index 00000000..111cbf56 --- /dev/null +++ b/docs/ai.md @@ -0,0 +1,860 @@ +# AI Object Model Reference + +This document describes the AI object model in git-internal: the types, +lifecycle, relationships, and usage patterns for AI-assisted code generation +workflows. All types live under `src/internal/object/`. + +## Overview + +The AI object model extends Git's native object types (Blob, Tree, Commit, Tag) +with a set of workflow objects that capture the full lifecycle of an AI-assisted +code change — from the initial user request to the final committed patch. + +``` +User input + │ + ▼ + Intent ──▶ Plan ──▶ Task ──▶ Run ──▶ PatchSet ──▶ Decision + │ + ├──▶ ToolInvocation (action log) + ├──▶ Evidence (validation results) + └──▶ Provenance (LLM metadata) +``` + +Context is tracked by two complementary mechanisms: + +- **ContextSnapshot** — static capture of files/URLs/snippets at Run start +- **ContextPipeline** — dynamic accumulation of incremental context frames + throughout execution + +## End-to-End Flow + +``` + ① User input + │ + ▼ + ② Intent (Draft → Active) + │ + ├──▶ ContextPipeline ← seeded with IntentAnalysis frame + │ + ▼ + ③ Plan (pipeline, fwindow, steps) + │ + ├─ PlanStep₀ (inline) + ├─ PlanStep₁ ──task──▶ sub-Task (recursive) + └─ PlanStep₂ (inline) + │ + ▼ + ④ Task (Draft → Running) + │ + ▼ + ⑤ Run (Created → Patching → Validating → Completed/Failed) + │ + ├──▶ Provenance (1:1, LLM config + token usage) + ├──▶ ContextSnapshot (optional, static context at start) + │ + │ ┌─── agent execution loop ───┐ + │ │ │ + │ │ ⑥ ToolInvocation (1:N) │ ← action log + │ │ │ │ + │ │ ▼ │ + │ │ ⑦ PatchSet (Proposed) │ ← candidate diff + │ │ │ │ + │ │ ▼ │ + │ │ ⑧ Evidence (1:N) │ ← test/lint/build + │ │ │ │ + │ │ ├─ pass ─────────────┘ + │ │ └─ fail → new PatchSet (retry within Run) + │ └────────────────────────────┘ + │ + ▼ + ⑨ Decision (terminal verdict) + │ + ├─ Commit → apply PatchSet, record result_commit + ├─ Retry → create new Run ⑤ for same Task + ├─ Abandon → mark Task as Failed + ├─ Checkpoint → save state, resume later + └─ Rollback → revert applied PatchSet + │ + ▼ + ⑩ Intent (Completed) ← commit recorded +``` + +### Steps + +1. **User input** — the user provides a natural-language request. + +2. **Intent** — captures the raw prompt and the AI's structured interpretation. + Status transitions from `Draft` (prompt only) to `Active` (analysis + complete). Supports conversational refinement via `parent` chain. + +3. **Plan** — a sequence of `PlanStep`s derived from the Intent. References a + `ContextPipeline` and records the visible frame range (`fwindow`). Steps + track consumed/produced frames by stable ID (`iframes`/`oframes`). A step may spawn a + sub-Task for recursive decomposition. Plans form a revision chain via + `previous`. + +4. **Task** — a unit of work with title, constraints, and acceptance criteria. + May link back to its originating Intent. Accumulates Runs in `runs` + (chronological execution history). + +5. **Run** — a single execution attempt of a Task. Records the baseline + `commit`, the Plan version being executed (snapshot reference), and the host + `environment`. A `Provenance` (1:1) captures the LLM configuration and + token usage. + +6. **ToolInvocation** — the finest-grained record: one per tool call (read + file, run command, etc.). Forms a chronological action log for the Run. + Tracks file I/O via `io_footprint`. + +7. **PatchSet** — a candidate diff generated by the agent. Contains the diff + `artifact`, file-level `touched` summary, and `rationale`. Starts as + `Proposed`; transitions to `Applied` or `Rejected`. Ordering is by + position in `Run.patchsets`. + +8. **Evidence** — output of a validation tool (test, lint, build) run against a + PatchSet. One per tool invocation. Carries `exit_code`, `summary`, and + `report_artifacts`. Feeds into the Decision. + +9. **Decision** — the terminal verdict of a Run. Selects a PatchSet to apply + (`Commit`), retries the Task (`Retry`), gives up (`Abandon`), saves + progress (`Checkpoint`), or reverts (`Rollback`). Records `rationale` + and `result_commit_sha`. + +10. **Intent completed** — the orchestrator records the final git commit in + `Intent.commit` and transitions status to `Completed`. + +## Object Relationship Summary + +| From | Field | To | Cardinality | +|------|-------|----|-------------| +| Intent | `parent` | Intent | 0..1 | +| Intent | `plan` | Plan | 0..1 | +| Plan | `previous` | Plan | 0..1 | +| Plan | `pipeline` | ContextPipeline | 0..1 | +| PlanStep | `task` | Task | 0..1 | +| Task | `parent` | Task | 0..1 | +| Task | `intent` | Intent | 0..1 | +| Task | `runs` | Run | 0..N | +| Task | `dependencies` | Task | 0..N | +| Run | `task` | Task | 1 | +| Run | `plan` | Plan | 0..1 | +| Run | `snapshot` | ContextSnapshot | 0..1 | +| Run | `patchsets` | PatchSet | 0..N | +| PatchSet | `run` | Run | 1 | +| Evidence | `run_id` | Run | 1 | +| Evidence | `patchset_id` | PatchSet | 0..1 | +| Decision | `run_id` | Run | 1 | +| Decision | `chosen_patchset_id` | PatchSet | 0..1 | +| Provenance | `run_id` | Run | 1 | +| ToolInvocation | `run_id` | Run | 1 | + +## Object Details + +### Intent (`intent.rs`) + +The **entry point** of every AI-assisted workflow. Captures the verbatim user +request (`prompt`) and the AI's structured interpretation (`content`). + +#### Status Transitions + +``` +Draft ──▶ Active ──▶ Completed + │ │ + └──────────┴──▶ Cancelled +``` + +- **Draft**: Prompt recorded, AI has not analyzed it yet. `content = None`. +- **Active**: AI interpretation available in `content`. Plan, Tasks, and Runs + may be in progress. +- **Completed**: All downstream work finished. `commit` contains the result + git commit hash. +- **Cancelled**: Abandoned before completion (user interrupt, timeout, budget). + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata (ID, type, timestamps, creator) | +| `prompt` | `String` | Verbatim user input, immutable after creation | +| `content` | `Option` | AI-analyzed interpretation; `None` in Draft | +| `parent` | `Option` | Predecessor Intent for conversational refinement | +| `commit` | `Option` | Result git commit, set at step ⑩ | +| `plan` | `Option` | Latest Plan revision derived from this Intent | +| `statuses` | `Vec` | Append-only status transition history | + +#### Conversational Refinement + +``` +Intent₀ ("Add pagination") + ▲ + │ parent +Intent₁ ("Also add cursor-based pagination") + ▲ + │ parent +Intent₂ ("Use opaque cursors, not offsets") +``` + +Each follow-up Intent links to its predecessor via `parent`, forming a +singly-linked list from newest to oldest. + +#### Usage + +```rust +use git_internal::internal::object::intent::{Intent, IntentStatus}; +use git_internal::internal::object::types::ActorRef; + +// 1. Create from user input +let actor = ActorRef::human("alice")?; +let mut intent = Intent::new(actor, "Add pagination to the user list API")?; +assert_eq!(intent.status(), &IntentStatus::Draft); + +// 2. AI analyzes the prompt +intent.set_content(Some("Add offset/limit pagination to GET /users".into())); +intent.set_status(IntentStatus::Active); + +// 3. After execution completes +intent.set_status_with_reason(IntentStatus::Completed, "All tasks finished"); +``` + +--- + +### Plan (`plan.rs`) + +A sequence of `PlanStep`s derived from an Intent's analyzed content. Defines +*what* to do (strategy and decomposition); `Run` handles *how* to execute. + +#### Revision Chain + +When the agent encounters obstacles, it creates a revised Plan via +`Plan::new_revision`. Each revision links back via `previous`: + +``` +Intent.plan ──▶ Plan_v3 (latest) + │ previous + ▼ + Plan_v2 + │ previous + ▼ + Plan_v1 (original, previous = None) +``` + +`Intent.plan` always points to the latest revision. Each `Run.plan` is a +**snapshot reference** that never changes. + +#### Context Range + +A Plan references a `ContextPipeline` via `pipeline` and records the visible +frame range `fwindow = (start, end)` — the half-open range `[start..end)` of +frames that were visible at creation time. + +``` +ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] + ^^^^^^^^^^^^^^^^ + fwindow = (0, 4) +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `previous` | `Option` | Predecessor Plan in revision chain | +| `pipeline` | `Option` | ContextPipeline used as context basis | +| `fwindow` | `Option<(u32, u32)>` | Visible frame range `[start..end)` | +| `steps` | `Vec` | Ordered sequence of steps | + +#### PlanStep + +Each step describes one unit of work within a Plan. + +| Field | Type | Description | +|-------|------|-------------| +| `description` | `String` | What this step should accomplish | +| `inputs` | `Option` | Expected inputs (JSON) | +| `outputs` | `Option` | Outputs after execution (JSON) | +| `checks` | `Option` | Validation criteria (JSON) | +| `iframes` | `Vec` | Stable pipeline frame IDs consumed (survives eviction) | +| `oframes` | `Vec` | Stable pipeline frame IDs produced (survives eviction) | +| `task` | `Option` | Sub-Task for recursive decomposition | +| `statuses` | `Vec` | Append-only status history | + +Step status transitions: + +``` +Pending ──▶ Progressing ──▶ Completed + │ │ + ├─────────────┴──▶ Failed + └──────────────────▶ Skipped +``` + +#### Recursive Decomposition + +A step can spawn a sub-Task via its `task` field: + +``` +Plan + ├─ Step₀ (inline — executed by current Run) + ├─ Step₁ ──task──▶ Task₁ + │ └─ Run → Plan + │ ├─ Step₁₋₀ + │ └─ Step₁₋₁ + └─ Step₂ (inline) +``` + +#### Usage + +```rust +use git_internal::internal::object::plan::{Plan, PlanStep}; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::agent("planner")?; + +// Create initial plan +let mut plan = Plan::new(actor.clone())?; +plan.set_pipeline(Some(pipeline_id)); +plan.set_fwindow(Some((0, 3))); + +// Add steps +let mut step = PlanStep::new("Refactor auth module"); +step.set_iframes(vec![0, 1]); // consumed frames 0 and 1 +plan.add_step(step); + +// Create a revision +let plan_v2 = plan.new_revision(actor)?; +assert_eq!(plan_v2.previous(), Some(plan.header().object_id())); +``` + +--- + +### Task (`task.rs`) + +A unit of work with constraints and acceptance criteria. The **stable +identity** for a piece of work — persists across retries and replanning. + +#### Status Transitions + +``` +Draft ──▶ Running ──▶ Done + │ │ + ├──────────┴──▶ Failed + └──────────────▶ Cancelled +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `title` | `String` | Short summary (< 100 chars) | +| `description` | `Option` | Extended context | +| `goal` | `Option` | Work classification (Feature, Bugfix, etc.) | +| `constraints` | `Vec` | Hard rules the solution must satisfy | +| `acceptance_criteria` | `Vec` | Testable success conditions | +| `requester` | `Option` | Who requested the work | +| `parent` | `Option` | Parent Task (for sub-Tasks) | +| `intent` | `Option` | Originating Intent | +| `runs` | `Vec` | Chronological execution history | +| `dependencies` | `Vec` | Tasks that must complete first | +| `status` | `TaskStatus` | Current lifecycle status | + +#### GoalType + +`Feature`, `Bugfix`, `Refactor`, `Docs`, `Perf`, `Test`, `Chore`, `Build`, +`Ci`, `Style`, `Other(String)`. + +#### Replanning + +The Task stays the same across Plan revisions. Each Run records which Plan +version it executed: + +``` +Task (constant) Intent (constant, plan updated) + │ └─ plan ──▶ Plan_v2 (latest) + └─ runs: + Run₀ ──plan──▶ Plan_v1 (snapshot: original plan) + Run₁ ──plan──▶ Plan_v2 (snapshot: revised plan) +``` + +#### Usage + +```rust +use git_internal::internal::object::task::{Task, GoalType}; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::human("user")?; +let mut task = Task::new(actor, "Refactor Login", Some(GoalType::Refactor))?; + +task.add_constraint("Must use JWT"); +task.add_acceptance_criterion("All tests pass"); +task.set_intent(Some(intent_id)); + +// After Run completes +task.add_run(run_id); +task.set_status(TaskStatus::Done); +``` + +--- + +### Run (`run.rs`) + +A single execution attempt of a Task. Captures the execution context +(baseline commit, environment, Plan version) and accumulates artifacts +(PatchSets, Evidence, ToolInvocations) during execution. + +#### Status Transitions + +``` +Created ──▶ Patching ──▶ Validating ──▶ Completed + │ │ + └──────────────┴──▶ Failed +``` + +- **Created**: Run initialized, environment captured. +- **Patching**: Agent is generating code changes / tool calls. +- **Validating**: Agent has produced a PatchSet and is running tests/lint. +- **Completed**: Decision has been created. +- **Failed**: Unrecoverable error. `Run.error` has details. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `task` | `Uuid` | Owning Task (mandatory) | +| `plan` | `Option` | Plan version being executed (snapshot) | +| `commit` | `IntegrityHash` | Baseline git commit | +| `status` | `RunStatus` | Current lifecycle status | +| `snapshot` | `Option` | ContextSnapshot at Run start | +| `patchsets` | `Vec` | Candidate diffs (chronological) | +| `metrics` | `Option` | Execution metrics (JSON) | +| `error` | `Option` | Error message if Failed | +| `environment` | `Option` | Host OS/arch/cwd | + +#### Associated Objects (by `run_id`) + +| Object | Cardinality | Purpose | +|--------|-------------|---------| +| Provenance | 1:1 | LLM config + token usage | +| ToolInvocation | 1:N | Chronological action log | +| Evidence | 1:N | Validation results (test/lint/build) | +| Decision | 1:1 | Terminal verdict | + +#### Usage + +```rust +use git_internal::internal::object::run::Run; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::agent("orchestrator")?; +let mut run = Run::new(actor, task_id, "abc123def...")?; +run.set_plan(Some(plan_id)); + +// Agent generates patches +run.set_status(RunStatus::Patching); +run.add_patchset(patchset_id); + +// Validation phase +run.set_status(RunStatus::Validating); + +// Completed +run.set_status(RunStatus::Completed); +``` + +--- + +### PatchSet (`patchset.rs`) + +A candidate diff generated by the agent during a Run. The atomic unit of +code modification — every change the agent wants to make is packaged as a +PatchSet. + +#### Lifecycle + +``` + (created) ──▶ Proposed + │ + ┌──────────────┼──────────────┐ + │ │ │ + ▼ ▼ │ + Applied Rejected │ + │ │ + ▼ │ + agent generates new PatchSet + appended to Run.patchsets +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run` | `Uuid` | Owning Run | +| `commit` | `IntegrityHash` | Baseline commit this diff applies to | +| `format` | `DiffFormat` | `Unified` or `GitDiff` | +| `artifact` | `Option` | Reference to stored diff content | +| `touched` | `Vec` | File-level change summary | +| `rationale` | `Option` | Agent's explanation of what/why changed | +| `apply_status` | `ApplyStatus` | `Proposed`, `Applied`, or `Rejected` | + +#### Rationale + +The `rationale` field bridges the gap between the Task/Plan (high-level intent) +and the raw diff (low-level changes). It is primarily written by the agent and +may be overridden by a human reviewer via `set_rationale()`. + +--- + +### Evidence (`evidence.rs`) + +Output of a single validation step (test, lint, build) run against a PatchSet. +One Evidence per tool invocation. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | Owning Run | +| `patchset_id` | `Option` | PatchSet being validated (`None` for run-level checks) | +| `kind` | `EvidenceKind` | `Test`, `Lint`, `Build`, or `Other(String)` | +| `tool` | `String` | Tool name (e.g. "cargo", "eslint", "pytest") | +| `command` | `Option` | Full command line for reproducibility | +| `exit_code` | `Option` | Process exit code (0 = success) | +| `summary` | `Option` | Short result summary | +| `report_artifacts` | `Vec` | Full report files (logs, coverage, JUnit XML) | + +--- + +### Decision (`decision.rs`) + +The **terminal verdict** of a Run. Created once per Run at the end of +execution. + +#### Decision Types + +| Type | Action | `chosen_patchset_id` | `result_commit_sha` | +|------|--------|---------------------|---------------------| +| `Commit` | Apply the chosen PatchSet | Set | Set after commit | +| `Checkpoint` | Save intermediate progress | — | — | +| `Abandon` | Give up on the Task | — | — | +| `Retry` | Create new Run for same Task | — | — | +| `Rollback` | Revert applied PatchSet | — | — | + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | The Run this Decision concludes | +| `decision_type` | `DecisionType` | Verdict (Commit/Retry/Abandon/etc.) | +| `chosen_patchset_id` | `Option` | Selected PatchSet (for Commit) | +| `result_commit_sha` | `Option` | Git commit hash after applying | +| `checkpoint_id` | `Option` | Saved state ID (for Checkpoint) | +| `rationale` | `Option` | Explanation of why this decision was made | + +--- + +### Provenance (`provenance.rs`) + +Records **how** a Run was executed: which LLM provider, model, and parameters +were used, and how many tokens were consumed. Created once per Run (1:1). + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | The Run this Provenance describes | +| `provider` | `String` | LLM provider ("openai", "anthropic", "local") | +| `model` | `String` | Model ID ("gpt-4", "claude-opus-4-20250514") | +| `parameters` | `Option` | Provider-specific raw parameters (JSON) | +| `temperature` | `Option` | Sampling temperature (0.0 = deterministic) | +| `max_tokens` | `Option` | Maximum generation length | +| `token_usage` | `Option` | Token consumption and cost | + +#### TokenUsage + +| Field | Type | Description | +|-------|------|-------------| +| `input_tokens` | `u64` | Tokens in the prompt/input | +| `output_tokens` | `u64` | Tokens in the completion/output | +| `total_tokens` | `u64` | `input_tokens + output_tokens` | +| `cost_usd` | `Option` | Estimated cost in USD | + +--- + +### ToolInvocation (`tool.rs`) + +The finest-grained record: one per tool call made by the agent during a Run. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `run_id` | `Uuid` | Owning Run | +| `tool_name` | `String` | Registered tool name ("read_file", "bash", etc.) | +| `io_footprint` | `Option` | Files read/written | +| `args` | `Value` | Arguments (JSON, tool-dependent schema) | +| `status` | `ToolStatus` | `Ok` or `Error` | +| `result_summary` | `Option` | Short output summary | +| `artifacts` | `Vec` | Full output files | + +#### IoFootprint + +| Field | Type | Description | +|-------|------|-------------| +| `paths_read` | `Vec` | Files read (repo-relative) | +| `paths_written` | `Vec` | Files written (repo-relative) | + +--- + +### ContextSnapshot (`context.rs`) + +A **static** capture of the codebase and external resources the agent observed +when a Run began. Point-in-time — does not change after creation. + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `selection_strategy` | `SelectionStrategy` | `Explicit` (user-chosen) or `Heuristic` (auto-selected) | +| `items` | `Vec` | Context items (files, URLs, snippets, etc.) | +| `summary` | `Option` | Aggregated summary | + +#### ContextItem + +Each item has three layers: + +- **`path`** — human-readable locator (repo path, URL, command, label) +- **`blob`** — Git blob hash pointing to full content at capture time +- **`preview`** — truncated text for quick display + +| Kind | `path` example | `blob` content | +|------|----------------|----------------| +| `File` | `src/main.rs` | Same blob in git tree (zero extra storage) | +| `Url` | `https://docs.rs/...` | Fetched page content | +| `Snippet` | `"design notes"` | Snippet text | +| `Command` | `cargo test` | Command output | +| `Image` | `screenshot.png` | Image binary | + +#### Blob Retention + +Blobs referenced only by AI objects are not reachable in git's DAG and will be +pruned by `git gc`. For non-File items, applications must choose a retention +strategy: + +| Strategy | Approach | +|----------|----------| +| Ref anchoring | `refs/ai/blobs/` | +| Orphan commit | `refs/ai/uploads` tree | +| Keep pack | `.keep` marker on pack file | +| Custom GC mark | Scan AI objects during GC | + +--- + +### ContextPipeline (`pipeline.rs`) + +A **dynamic** context container that accumulates incremental `ContextFrame`s +throughout the workflow. Solves the context-forgetting problem in long-running +tasks. + +#### Lifecycle + +``` +Intent (Active) ← content analyzed + │ + ▼ +ContextPipeline created ← seeded with IntentAnalysis frame + │ + ▼ +Plan created ← Plan.pipeline → Pipeline, Plan.fwindow = range + │ steps execute + ▼ +Frames accumulate ← StepSummary, CodeChange, ToolCall, ... + │ + ▼ +Replan? → new Plan with updated fwindow +``` + +#### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `header` | `Header` | Common metadata | +| `frames` | `Vec` | Chronologically ordered frames | +| `next_frame_id` | `u64` | Monotonic counter for stable frame IDs | +| `max_frames` | `u32` | Max frames before eviction (0 = unlimited) | +| `global_summary` | `Option` | Aggregated summary | + +#### ContextFrame + +| Field | Type | Description | +|-------|------|-------------| +| `frame_id` | `u64` | Stable monotonic ID (survives eviction) | +| `kind` | `FrameKind` | IntentAnalysis, StepSummary, CodeChange, etc. | +| `summary` | `String` | Compact human-readable summary | +| `data` | `Option` | Structured payload (JSON) | +| `created_at` | `DateTime` | When this frame was created | +| `token_estimate` | `Option` | Estimated tokens for budgeting | + +#### FrameKind + +| Kind | Protected | Description | +|------|-----------|-------------| +| `IntentAnalysis` | Yes | Seed frame from Intent analysis | +| `StepSummary` | No | Summary after a PlanStep completes | +| `CodeChange` | No | Code change digest (files, diff stats) | +| `SystemState` | No | System/environment state snapshot | +| `ErrorRecovery` | No | Error recovery context | +| `Checkpoint` | Yes | Explicit save-point | +| `ToolCall` | No | External tool invocation result | +| `Other(String)` | No | Application-defined | + +Protected frames survive eviction when `max_frames` is exceeded. + +#### Eviction + +When `max_frames > 0` and the limit is exceeded, `push_frame` removes the +oldest non-protected frame. `IntentAnalysis` and `Checkpoint` frames are +never evicted. + +#### Step-Frame Association + +Steps track their relationship to frames via stable frame IDs: + +``` +ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅] + │ │ ▲ + ╰────╯ │ + iframes=[0,1] oframes=[4] + ╰── Step₀ ──╯ +``` + +- `iframes` — stable `frame_id`s of frames the step consumed as context +- `oframes` — stable `frame_id`s of frames the step produced + +Frame IDs are monotonic integers assigned by `push_frame`. Unlike Vec +indices, IDs survive eviction — a step's `iframes` remain valid even +after older frames are removed. Look up frames via `frame_by_id`. + +All association is owned by the step; `ContextFrame` has no back-references. + +#### Usage + +```rust +use git_internal::internal::object::pipeline::{ContextPipeline, FrameKind}; +use git_internal::internal::object::types::ActorRef; + +let actor = ActorRef::agent("orchestrator")?; + +// 1. Create pipeline after Intent content is analyzed +let mut pipeline = ContextPipeline::new(actor)?; + +// 2. Seed with the Intent's analyzed content — returns stable frame_id +let seed_id = pipeline.push_frame( + FrameKind::IntentAnalysis, + "Add offset/limit pagination to GET /users with default page size 20", +); + +// 3. Create a Plan referencing this pipeline +// plan.set_pipeline(Some(pipeline.header().object_id())); +// plan.set_fwindow(Some((0, pipeline.frames().len() as u32))); + +// 4. As steps complete, push incremental frames +let step_id = pipeline.push_frame(FrameKind::StepSummary, "Refactored auth module"); + +// 5. Track on PlanStep side using stable frame IDs: +// step.set_iframes(vec![seed_id]); +// step.set_oframes(vec![step_id]); + +// 6. Look up frame by ID (survives eviction) +// pipeline.frame_by_id(seed_id); +``` + +## Common Header + +All AI objects share a common `Header` (flattened into the JSON via `#[serde(flatten)]`): + +| Field | Type | Description | +|-------|------|-------------| +| `object_id` | `Uuid` | Globally unique ID (UUID v7, time-ordered) | +| `object_type` | `ObjectType` | Discriminator (Intent, Plan, Task, etc.) | +| `header_version` | `u32` | Format version of the Header struct | +| `schema_version` | `u32` | Per-object-type schema version | +| `created_at` | `DateTime` | Creation timestamp | +| `updated_at` | `DateTime` | Last modification timestamp | +| `created_by` | `ActorRef` | Who created this object | +| `visibility` | `Visibility` | `Private` or `Public` | +| `tags` | `HashMap` | Free-form search tags | +| `external_ids` | `HashMap` | External ID mapping | +| `checksum` | `Option` | Content checksum (set by `seal()`) | + +### ActorRef + +Identifies who created or triggered an action: + +| Kind | Factory | Example | +|------|---------|---------| +| `Human` | `ActorRef::human("alice")` | A user | +| `Agent` | `ActorRef::agent("coder")` | An AI agent | +| `System` | `ActorRef::system("scheduler")` | Infrastructure | +| `McpClient` | `ActorRef::mcp_client("vscode")` | MCP client | + +### ArtifactRef + +Reference to external content stored outside the AI object: + +| Field | Type | Description | +|-------|------|-------------| +| `store` | `String` | Storage backend ("local", "s3") | +| `key` | `String` | Storage key or path | +| `content_type` | `Option` | MIME type | +| `size_bytes` | `Option` | Size in bytes | +| `hash` | `Option` | Content hash (SHA-256) | +| `expires_at` | `Option>` | Expiration time | + +Supports integrity verification via `verify_integrity(content)` and +content deduplication via `content_eq(other)`. + +## Serialization + +All AI objects implement `ObjectTrait` with JSON serialization: + +- **`from_bytes(data, hash)`** — deserialize from JSON bytes +- **`to_data()`** — serialize to JSON bytes +- **`get_type()`** — returns the `ObjectType` discriminator +- **`get_size()`** — returns the serialized size +- **`object_hash()`** — computes the content-addressable hash + +AI object types are registered in `ObjectType` with numeric IDs 8–18 and +string identifiers for serialization (e.g. `"intent"`, `"plan"`, `"task"`). + +> **Pack encoding**: AI objects cannot be encoded in standard Git pack headers +> (which only support 3-bit type values 1–7). They must use the AI-specific +> storage layer. `ObjectType::to_pack_type_u8()` returns an error for AI types. + +## Design Principles + +1. **Append-only history**: Status changes (Intent, PlanStep) and execution + records (Task.runs, Run.patchsets) are append-only — entries are never + removed or mutated. + +2. **Snapshot references**: Run.plan records the Plan version at execution + time. Intent.plan always points to the latest revision. This allows + replanning without invalidating in-progress Runs. + +3. **Bidirectional references**: Key relationships are bidirectional for + efficient traversal (Task ↔ Run, Run ↔ PatchSet). Other relationships + are unidirectional with reverse lookup by scanning (Evidence → Run, + Decision → Run). + +4. **Serde conventions**: Optional fields use `#[serde(default, skip_serializing_if)]`. + Empty Vecs use `skip_serializing_if = "Vec::is_empty"`. Field renames use + `#[serde(alias = "old_name")]` for backward compatibility. + +5. **Context separation**: Static context (ContextSnapshot) vs. dynamic + context (ContextPipeline) serve complementary needs — reproducibility + vs. continuity. diff --git a/src/errors.rs b/src/errors.rs index d564c5f9..0841822e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -98,6 +98,10 @@ pub enum GitError { #[error("Not a valid agent tool invocation object: {0}")] InvalidToolInvocationObject(String), + /// Malformed context pipeline object. + #[error("Not a valid agent context pipeline object: {0}")] + InvalidContextPipelineObject(String), + /// Malformed or unsupported index (.idx) file. #[error("The `{0}` is not a valid idx file.")] InvalidIdxFile(String), diff --git a/src/internal/object/context.rs b/src/internal/object/context.rs index 8e275645..0ff8b040 100644 --- a/src/internal/object/context.rs +++ b/src/internal/object/context.rs @@ -1,79 +1,176 @@ //! AI Context Snapshot Definition //! -//! A `ContextSnapshot` represents the state of the codebase and external resources -//! that an agent uses to perform its task. +//! A [`ContextSnapshot`] is an optional static capture of the codebase +//! and external resources that an agent observed when a +//! [`Run`](super::run::Run) began. Unlike the dynamic +//! [`ContextPipeline`](super::pipeline::ContextPipeline) (which +//! accumulates frames during execution), a ContextSnapshot is a +//! **point-in-time** record that does not change after creation. //! -//! # Selection Strategy +//! # Position in Lifecycle //! -//! - **Explicit**: User manually selected files. -//! - **Heuristic**: Agent automatically selected files based on relevance. +//! ```text +//! ⑤ Run ──snapshot──▶ ContextSnapshot (optional, static) +//! │ +//! ├──▶ ContextPipeline (dynamic, via Plan.pipeline) +//! │ +//! ▼ +//! ⑥ ToolInvocations ... +//! ``` //! -//! # Integrity +//! A ContextSnapshot is created at step ⑤ when the Run is initialized. +//! It complements the ContextPipeline: the snapshot captures the +//! **initial** state (what files, URLs, snippets the agent sees at +//! start), while the pipeline tracks **incremental** context changes +//! during execution. //! -//! Each item in the snapshot has a content hash (`IntegrityHash`). -//! This ensures that if the file changes on disk, we know the snapshot is stale or refers to an older version. +//! # Items +//! +//! Each [`ContextItem`] has three layers: +//! +//! - **`path`** — human-readable locator (repo path, URL, command, +//! label). +//! - **`blob`** — Git blob hash pointing to the **full content** at +//! capture time. +//! - **`preview`** — truncated text for quick display without reading +//! the blob. +//! +//! All item kinds use `blob` as the unified content reference: +//! +//! | Kind | `path` example | `blob` content | +//! |---|---|---| +//! | `File` | `src/main.rs` | Same blob in git tree (zero extra storage) | +//! | `Url` | `https://docs.rs/...` | Fetched page content stored as blob | +//! | `Snippet` | `"design notes"` | Snippet text stored as blob | +//! | `Command` | `cargo test` | Command output stored as blob | +//! | `Image` | `screenshot.png` | Image binary stored as blob | +//! +//! `blob` is `Option` because it may be `None` during the +//! draft/collection phase; by the time the snapshot is finalized, +//! items should have their blob set. +//! +//! # Blob Retention +//! +//! Standard `git gc` only considers objects reachable from +//! refs → commits → trees → blobs. A blob referenced solely by an AI +//! object's JSON payload is **not** reachable in git's graph and +//! **will be pruned** after `gc.pruneExpire` (default 2 weeks). +//! +//! For `File` items this is not a concern — the blob is already +//! reachable through the commit tree. For all other kinds, +//! applications must choose a retention strategy: +//! +//! | Strategy | Pros | Cons | +//! |---|---|---| +//! | **Ref anchoring** (`refs/ai/blobs/`) | Simple, works with stock git | Ref namespace pollution | +//! | **Orphan commit** (`refs/ai/uploads`) | Standard reachability; packable | Extra commit/tree overhead | +//! | **Keep pack** (`.keep` marker) | Zero ref management | Must repack manually | +//! | **Custom GC mark** (scan AI objects) | Cleanest long-term | Requires custom gc | +//! +//! This library does **not** enforce any particular strategy — the +//! consuming application is responsible for ensuring referenced blobs +//! remain reachable. +//! +//! # Purpose +//! +//! - **Reproducibility**: Given the same ContextSnapshot and Plan, an +//! agent should produce equivalent results. +//! - **Auditing**: Reviewers can inspect exactly what context the agent +//! had access to when making decisions. +//! - **Content Deduplication**: Using Git blob hashes means identical +//! file content is stored only once, regardless of how many snapshots +//! reference it. use std::fmt::Display; use serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::{ errors::GitError, hash::ObjectHash, internal::object::{ ObjectTrait, - integrity::IntegrityHash, types::{ActorRef, Header, ObjectType}, }, }; -/// Selection strategy for context snapshots. +/// How the items in a [`ContextSnapshot`] were selected. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SelectionStrategy { - /// Files explicitly chosen by the user. + /// Items were explicitly chosen by the user (e.g. "look at these + /// files"). The agent should treat these as authoritative context. Explicit, - /// Files automatically selected by the agent/system. + /// Items were automatically selected by the agent or system based + /// on relevance heuristics (e.g. file dependency analysis, search + /// results). The agent may decide to fetch additional context. Heuristic, } -/// Context item kind. +/// The kind of content a [`ContextItem`] represents. +/// +/// Determines how `path` and `blob` should be interpreted. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ContextItemKind { - /// A regular file in the repository. + /// A regular file in the repository. `path` is a repo-relative + /// path (e.g. `src/main.rs`). `blob` is the same object already + /// in the git tree (zero extra storage). File, - /// A URL (web page, API endpoint, etc.). + /// A URL (web page, API docs, etc.). `path` is the full URL. + /// `blob` contains the fetched page content. Url, - /// A free-form text snippet (e.g. doc fragment, note). + /// A free-form text snippet (e.g. design note, doc fragment). + /// `path` is a descriptive label. `blob` contains the snippet text. Snippet, - /// Command or terminal output. + /// Command or terminal output. `path` is the command that was run + /// (e.g. `cargo test`). `blob` contains the captured output. Command, - /// Image or other binary visual content. + /// Image or other binary visual content. `path` is the file name. + /// `blob` contains the raw binary data. Image, + /// Application-defined kind not covered by the variants above. Other(String), } -/// Context item describing a single input. +/// A single input item within a [`ContextSnapshot`]. +/// +/// Represents one piece of context the agent has access to — a source +/// file, a URL, a text snippet, command output, or an image. See +/// module documentation for the three-layer design (`path` / `blob` / +/// `preview`) and blob retention strategies. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextItem { + /// The kind of content this item represents. Determines how + /// `path` and `blob` should be interpreted. pub kind: ContextItemKind, + /// Human-readable locator for this item. + /// + /// Meaning depends on `kind`: repo-relative path for `File`, + /// full URL for `Url`, descriptive label for `Snippet`, shell + /// command for `Command`, file name for `Image`. pub path: String, - pub content_id: IntegrityHash, - /// Optional preview/summary of the content (for example, first 200 characters). - /// Used for display without loading the full content via `content_id`. - /// Should be kept under 500 characters for performance. - #[serde(default)] - pub content_preview: Option, + /// Truncated preview of the content for quick display. + /// + /// Should be kept under 500 characters. `None` when no preview + /// is available (e.g. binary content, very short items where + /// the full content fits in `path`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, + /// Git blob hash referencing the **full content** at capture time. + /// + /// For `File` items, this is the same blob already in the git + /// tree (zero extra storage due to content-addressing). For + /// other kinds (Url, Snippet, Command, Image), the content is + /// stored as a new blob — see module-level docs for retention + /// strategies. `None` during the draft/collection phase; should + /// be set before the snapshot is finalized. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blob: Option, } impl ContextItem { - pub fn new( - kind: ContextItemKind, - path: impl Into, - content_id: IntegrityHash, - ) -> Result { + pub fn new(kind: ContextItemKind, path: impl Into) -> Result { let path = path.into(); if path.trim().is_empty() { return Err("path cannot be empty".to_string()); @@ -81,36 +178,53 @@ impl ContextItem { Ok(Self { kind, path, - content_id, - content_preview: None, + preview: None, + blob: None, }) } + + pub fn set_blob(&mut self, blob: Option) { + self.blob = blob; + } } -/// Context snapshot describing selected inputs. -/// Captures the selection strategy and content identifiers used by a run. +/// A static capture of the context an agent observed at Run start. +/// +/// Created once per Run (optional). Records which files, URLs, +/// snippets, etc. the agent had access to. See module documentation +/// for lifecycle position, item design, and blob retention. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextSnapshot { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, - base_commit_sha: IntegrityHash, + /// How the items were selected — by the user (`Explicit`) or + /// by the agent/system (`Heuristic`). selection_strategy: SelectionStrategy, - #[serde(default)] + /// The context items included in this snapshot. + /// + /// Each item references a piece of content (file, URL, snippet, + /// etc.) via its `blob` field. Items are ordered as added; no + /// implicit ordering is guaranteed. Empty when the snapshot has + /// just been created and items haven't been added yet. + #[serde(default, skip_serializing_if = "Vec::is_empty")] items: Vec, + /// Aggregated human-readable summary of all items. + /// + /// A brief description of the overall context (e.g. "3 source + /// files + API docs for /users endpoint"). `None` when no + /// summary has been provided. + #[serde(default, skip_serializing_if = "Option::is_none")] summary: Option, } impl ContextSnapshot { pub fn new( - repo_id: Uuid, created_by: ActorRef, - base_commit_sha: impl AsRef, selection_strategy: SelectionStrategy, ) -> Result { - let base_commit_sha = base_commit_sha.as_ref().parse()?; Ok(Self { - header: Header::new(ObjectType::ContextSnapshot, repo_id, created_by)?, - base_commit_sha, + header: Header::new(ObjectType::ContextSnapshot, created_by)?, selection_strategy, items: Vec::new(), summary: None, @@ -121,10 +235,6 @@ impl ContextSnapshot { &self.header } - pub fn base_commit_sha(&self) -> &IntegrityHash { - &self.base_commit_sha - } - pub fn selection_strategy(&self) -> &SelectionStrategy { &self.selection_strategy } @@ -185,33 +295,19 @@ mod tests { #[test] fn test_context_snapshot_accessors_and_mutators() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::agent("coder").expect("actor"); - let mut snapshot = ContextSnapshot::new( - repo_id, - actor, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", - SelectionStrategy::Heuristic, - ) - .expect("snapshot"); + let mut snapshot = + ContextSnapshot::new(actor, SelectionStrategy::Heuristic).expect("snapshot"); assert_eq!(snapshot.selection_strategy(), &SelectionStrategy::Heuristic); assert!(snapshot.items().is_empty()); assert!(snapshot.summary().is_none()); - let item = ContextItem::new( - ContextItemKind::File, - "src/main.rs", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .parse() - .expect("hash"), - ) - .expect("item"); + let item = ContextItem::new(ContextItemKind::File, "src/main.rs").expect("item"); snapshot.add_item(item); snapshot.set_summary(Some("selected by relevance".to_string())); assert_eq!(snapshot.items().len(), 1); assert_eq!(snapshot.summary(), Some("selected by relevance")); - assert_eq!(snapshot.base_commit_sha().to_hex().len(), 64); } } diff --git a/src/internal/object/decision.rs b/src/internal/object/decision.rs index 1cc81a37..4ac7c017 100644 --- a/src/internal/object/decision.rs +++ b/src/internal/object/decision.rs @@ -1,15 +1,55 @@ //! AI Decision Definition //! -//! `Decision` represents the final outcome of an agent's run. It signals whether the proposed -//! changes should be applied, rejected, or if the agent needs human intervention. +//! A `Decision` is the **terminal verdict** of a [`Run`](super::run::Run). +//! After the agent finishes generating and validating PatchSets, the +//! orchestrator (or the agent itself) creates a Decision to record what +//! should happen next. +//! +//! # Position in Lifecycle +//! +//! ```text +//! Task ──runs──▶ Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...] +//! │ +//! └──(terminal)──▶ Decision +//! ├── chosen_patchset ──▶ PatchSet +//! └── evidence (via Evidence.decision) +//! ``` +//! +//! A Decision is created **once per Run**, at the end of execution. +//! It selects which PatchSet (if any) to apply and records the +//! resulting commit hash. [`Evidence`](super::evidence::Evidence) +//! objects may reference the Decision to provide supporting data +//! (test results, lint reports) that justified the verdict. //! //! # Decision Types //! -//! - **Commit**: Changes are good, apply them. -//! - **Abandon**: Task is impossible or not worth doing. -//! - **Retry**: Something went wrong, try again (with different params/prompt). -//! - **Checkpoint**: Save progress but don't finish yet. -//! - **Rollback**: Revert changes. +//! - **`Commit`**: Accept the chosen PatchSet and apply it to the +//! repository. `chosen_patchset` and `result_commit` should be set. +//! - **`Checkpoint`**: Save intermediate progress without finishing. +//! The Run may continue or be resumed later. `checkpoint_id` +//! identifies the saved state. +//! - **`Abandon`**: Give up on the Task. The goal is deemed impossible +//! or not worth pursuing. No PatchSet is applied. +//! - **`Retry`**: The current attempt failed but the Task is still +//! viable. The orchestrator should create a new Run to try again, +//! potentially with different parameters or prompts. +//! - **`Rollback`**: Revert previously applied changes. Used when a +//! committed PatchSet is later found to be incorrect. +//! +//! # Flow +//! +//! ```text +//! Run completes +//! │ +//! ▼ +//! Orchestrator creates Decision +//! │ +//! ├─ Commit ──▶ apply PatchSet, record result_commit +//! ├─ Checkpoint ──▶ save state, record checkpoint_id +//! ├─ Abandon ──▶ mark Task as Failed +//! ├─ Retry ──▶ create new Run for same Task +//! └─ Rollback ──▶ revert applied PatchSet +//! ``` use std::fmt; @@ -83,30 +123,70 @@ impl From<&str> for DecisionType { } } -/// Decision object linking process to outcomes. -/// Records the final outcome of a run. +/// Terminal verdict of a [`Run`](super::run::Run). +/// +/// Created once per Run at the end of execution. See module +/// documentation for lifecycle position and decision type semantics. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, + /// The [`Run`](super::run::Run) this Decision concludes. + /// + /// Every Decision belongs to exactly one Run. The Run does not + /// store a back-reference; lookup is done by scanning or indexing. run_id: Uuid, + /// The verdict: what should happen as a result of this Run. + /// + /// See [`DecisionType`] variants for semantics. The orchestrator + /// inspects this field to determine the next action (apply patch, + /// retry, abandon, etc.). decision_type: DecisionType, + /// The [`PatchSet`](super::patchset::PatchSet) selected for + /// application. + /// + /// Set when `decision_type` is `Commit` — identifies which + /// PatchSet from `Run.patchsets` was chosen. `None` for + /// `Abandon`, `Retry`, `Rollback`, or when no suitable PatchSet + /// exists. + #[serde(default, skip_serializing_if = "Option::is_none")] chosen_patchset_id: Option, + /// Git commit hash produced after applying the chosen PatchSet. + /// + /// Set by the orchestrator after a successful `git commit`. + /// `None` until the PatchSet is actually committed, or when the + /// decision does not involve applying changes. + #[serde(default, skip_serializing_if = "Option::is_none")] result_commit_sha: Option, + /// Opaque identifier for a saved checkpoint. + /// + /// Set when `decision_type` is `Checkpoint`. The format and + /// resolution of the ID are defined by the orchestrator (e.g. + /// a snapshot name, a storage key). `None` for all other + /// decision types. + #[serde(default, skip_serializing_if = "Option::is_none")] checkpoint_id: Option, + /// Human-readable explanation of why this decision was made. + /// + /// Written by the agent or orchestrator to justify the verdict. + /// For `Commit`: summarises why the chosen PatchSet is correct. + /// For `Abandon`/`Retry`: explains what went wrong. + /// For `Rollback`: describes the defect that triggered reversion. + /// `None` if no explanation was provided. + #[serde(default, skip_serializing_if = "Option::is_none")] rationale: Option, } impl Decision { /// Create a new decision object pub fn new( - repo_id: Uuid, created_by: ActorRef, run_id: Uuid, decision_type: impl Into, ) -> Result { Ok(Self { - header: Header::new(ObjectType::Decision, repo_id, created_by)?, + header: Header::new(ObjectType::Decision, created_by)?, run_id, decision_type: decision_type.into(), chosen_patchset_id: None, @@ -200,13 +280,12 @@ mod tests { #[test] fn test_decision_fields() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::agent("test-agent").expect("actor"); let run_id = Uuid::from_u128(0x1); let patchset_id = Uuid::from_u128(0x2); let expected_hash = IntegrityHash::compute(b"decision-hash"); - let mut decision = Decision::new(repo_id, actor, run_id, "commit").expect("decision"); + let mut decision = Decision::new(actor, run_id, "commit").expect("decision"); decision.set_chosen_patchset_id(Some(patchset_id)); decision.set_result_commit_sha(Some(expected_hash)); decision.set_rationale(Some("tests passed".to_string())); diff --git a/src/internal/object/evidence.rs b/src/internal/object/evidence.rs index 1279ff5d..d1d75633 100644 --- a/src/internal/object/evidence.rs +++ b/src/internal/object/evidence.rs @@ -1,13 +1,38 @@ //! AI Evidence Definition //! -//! `Evidence` represents the result of a validation or quality assurance step, such as -//! running tests, linting code, or building artifacts. +//! An `Evidence` captures the output of a single validation or quality +//! assurance step — running tests, linting code, compiling the project, +//! etc. It is the objective data that supports (or contradicts) the +//! agent's proposed changes. +//! +//! # Position in Lifecycle +//! +//! ```text +//! Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...] +//! │ │ +//! │ ▼ +//! └──────────────▶ Evidence (run_id + optional patchset_id) +//! │ +//! ▼ +//! Decision (uses Evidence to justify verdict) +//! ``` +//! +//! Evidence is produced **during** a Run, typically after a PatchSet is +//! generated. The orchestrator runs validation tools against the +//! PatchSet and creates one Evidence per tool invocation. A single +//! PatchSet may have multiple Evidence objects (e.g. test + lint + +//! build). Evidence that is not tied to a specific PatchSet (e.g. a +//! pre-run environment check) sets `patchset_id` to `None`. //! //! # Purpose //! -//! - **Validation**: Proves that a patchset works as expected. -//! - **Feedback**: Provides error messages and logs to the agent so it can fix issues. -//! - **Decision Support**: Used by the `Decision` object to justify committing or rejecting changes. +//! - **Validation**: Proves that a PatchSet works as expected (tests +//! pass, code compiles, lint clean). +//! - **Feedback**: Provides error messages, logs, and exit codes to the +//! agent so it can fix issues and produce a better PatchSet. +//! - **Decision Support**: The [`Decision`](super::decision::Decision) +//! references Evidence to justify committing or rejecting changes. +//! Reviewers can inspect Evidence to understand why a verdict was made. use std::fmt; @@ -70,33 +95,76 @@ impl From<&str> for EvidenceKind { } } -/// Evidence object for test/lint/build results. -/// Links tooling output back to a run or patchset. +/// Output of a single validation step (test, lint, build, etc.). +/// +/// One Evidence per tool invocation. Multiple Evidence objects may +/// exist for the same PatchSet (one per validation tool). See module +/// documentation for lifecycle position. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Evidence { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, + /// The [`Run`](super::run::Run) during which this validation was + /// performed. Every Evidence belongs to exactly one Run. run_id: Uuid, + /// The [`PatchSet`](super::patchset::PatchSet) being validated. + /// + /// `None` for run-level checks that are not specific to any + /// PatchSet (e.g. environment health check before patching starts). + /// When set, the Evidence applies to that specific PatchSet. + #[serde(default, skip_serializing_if = "Option::is_none")] patchset_id: Option, + /// Category of validation performed. + /// + /// `Test` for unit/integration/e2e tests, `Lint` for static + /// analysis, `Build` for compilation. `Other(String)` for + /// categories not covered by the predefined variants. kind: EvidenceKind, + /// Name of the tool that produced this evidence (e.g. "cargo", + /// "eslint", "pytest"). Used for display and filtering. tool: String, + /// Full command line that was executed (e.g. "cargo test --release"). + /// + /// `None` if the tool was invoked programmatically without a + /// shell command. Useful for reproducibility — a reviewer can + /// re-run the exact same command locally. + #[serde(default, skip_serializing_if = "Option::is_none")] command: Option, + /// Process exit code returned by the tool. + /// + /// `0` typically means success; non-zero means failure. `None` if + /// the tool did not produce an exit code (e.g. an in-process check). + /// The orchestrator uses this as a quick pass/fail signal before + /// parsing the full report. + #[serde(default, skip_serializing_if = "Option::is_none")] exit_code: Option, - summary: Option, // passed/failed, error signature - #[serde(default)] + /// Short human-readable summary of the result. + /// + /// Typically a one-liner like "42 tests passed", "3 lint errors", + /// or an error signature extracted from the output. `None` if no + /// summary was produced. For full output, see `report_artifacts`. + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option, + /// References to full report files in object storage. + /// + /// May include log files, HTML coverage reports, JUnit XML, etc. + /// Each [`ArtifactRef`] points to one stored file. The list is + /// empty when the tool produced no persistent output, or when the + /// output is captured entirely in `summary`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] report_artifacts: Vec, } impl Evidence { pub fn new( - repo_id: Uuid, created_by: ActorRef, run_id: Uuid, kind: impl Into, tool: impl Into, ) -> Result { Ok(Self { - header: Header::new(ObjectType::Evidence, repo_id, created_by)?, + header: Header::new(ObjectType::Evidence, created_by)?, run_id, patchset_id: None, kind: kind.into(), @@ -204,13 +272,11 @@ mod tests { #[test] fn test_evidence_fields() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::agent("test-agent").expect("actor"); let run_id = Uuid::from_u128(0x1); let patchset_id = Uuid::from_u128(0x2); - let mut evidence = - Evidence::new(repo_id, actor, run_id, "test", "cargo").expect("evidence"); + let mut evidence = Evidence::new(actor, run_id, "test", "cargo").expect("evidence"); evidence.set_patchset_id(Some(patchset_id)); evidence.set_exit_code(Some(1)); evidence.add_report_artifact(ArtifactRef::new("local", "log.txt").expect("artifact")); diff --git a/src/internal/object/intent.rs b/src/internal/object/intent.rs index 8b1c02c6..0a36218c 100644 --- a/src/internal/object/intent.rs +++ b/src/internal/object/intent.rs @@ -1,5 +1,81 @@ +//! AI Intent Definition +//! +//! An [`Intent`] is the **entry point** of every AI-assisted workflow — it +//! captures the raw user request (`prompt`) and the AI's structured +//! interpretation of that request (`content`). The Intent is the first +//! object created (step ① → ②) and the last one completed (step ⑩) in +//! the end-to-end flow described in [`mod.rs`](super). +//! +//! # Position in Lifecycle +//! +//! ```text +//! ① User input (natural-language request) +//! │ +//! ▼ +//! ② Intent (Draft) ← prompt recorded, content = None +//! │ AI analysis +//! ▼ +//! Intent (Active) ← content filled, plan linked +//! │ +//! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame +//! │ +//! ▼ +//! ③ Plan (derived from content) +//! │ +//! ▼ +//! ④–⑨ Task → Run → PatchSet → Evidence → Decision +//! │ +//! ▼ +//! ⑩ Intent (Completed) ← commit recorded +//! ``` +//! +//! ## Conversational Refinement +//! +//! ```text +//! Intent₀ ("Add pagination") +//! ▲ +//! │ parent +//! Intent₁ ("Also add cursor-based pagination") +//! ▲ +//! │ parent +//! Intent₂ ("Use opaque cursors, not offsets") +//! ``` +//! +//! Each follow-up Intent links to its predecessor via `parent`, +//! forming a singly-linked list from newest to oldest. This +//! preserves the full conversational history without mutating +//! earlier Intents. +//! +//! ## Status Transitions +//! +//! ```text +//! Draft ──▶ Active ──▶ Completed +//! │ │ +//! └──────────┴──▶ Cancelled +//! ``` +//! +//! Status changes are **append-only**: each transition pushes a +//! [`StatusEntry`] onto the `statuses` vector. The current status +//! is always the last entry. This design preserves the full +//! transition history with timestamps and optional reasons. +//! +//! # Purpose +//! +//! - **Traceability**: Links the original human request to all +//! downstream artifacts (Plan, Tasks, Runs, PatchSets). Reviewers +//! can trace any code change back to the Intent that motivated it. +//! - **Reproducibility**: Stores both the verbatim prompt and the +//! AI's interpretation, allowing re-analysis with different models +//! or parameters. +//! - **Conversational Context**: The `parent` chain captures iterative +//! refinement, so the agent can understand how the user's request +//! evolved over multiple exchanges. +//! - **Completion Tracking**: The `commit` field closes the loop by +//! recording which git commit satisfied the Intent. + use std::fmt; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -13,16 +89,37 @@ use crate::{ }, }; +/// Status of an Intent through its lifecycle. +/// +/// Valid transitions (see module docs for diagram): +/// +/// - `Draft` → `Active`: AI has analyzed the prompt and filled `content`. +/// - `Active` → `Completed`: All downstream Tasks finished successfully +/// and the result commit has been recorded in `Intent.commit`. +/// - `Draft` → `Cancelled`: User abandoned the request before AI analysis. +/// - `Active` → `Cancelled`: User or orchestrator cancelled during +/// planning/execution (e.g. timeout, user interrupt, budget exceeded). +/// +/// Reverse transitions (e.g. `Active` → `Draft`) are not expected. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum IntentStatus { + /// Initial state. The `prompt` has been captured but the AI has not + /// yet analyzed it — `Intent.content` is `None`. Draft, + /// AI interpretation is available in `Intent.content`. Downstream + /// objects (Plan, Tasks, Runs) may be in progress. Active, + /// The Intent has been fully satisfied. `Intent.commit` should + /// contain the SHA of the git commit that fulfils the request. Completed, + /// The Intent was abandoned before completion. A reason should be + /// recorded in the [`StatusEntry`] that carries this status. Cancelled, } impl IntentStatus { + /// Returns the snake_case string representation. pub fn as_str(&self) -> &'static str { match self { IntentStatus::Draft => "draft", @@ -39,81 +136,224 @@ impl fmt::Display for IntentStatus { } } +/// A single entry in the Intent's status history. +/// +/// Each status transition appends a new `StatusEntry` to +/// `Intent.statuses`. The entries are never removed or mutated, +/// forming an append-only audit log. The current status is always +/// `statuses.last().status`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StatusEntry { + /// The [`IntentStatus`] that was entered by this transition. + status: IntentStatus, + /// UTC timestamp of when this transition occurred. + /// + /// Automatically set to `Utc::now()` by [`StatusEntry::new`]. + /// Timestamps across entries in the same Intent are monotonically + /// non-decreasing. + changed_at: DateTime, + /// Optional human-readable reason for the transition. + /// + /// Recommended for `Cancelled` (why the request was abandoned) and + /// `Completed` (summary of what was achieved). May be `None` for + /// routine transitions like `Draft` → `Active`. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, +} + +impl StatusEntry { + /// Creates a new status entry timestamped to now. + pub fn new(status: IntentStatus, reason: Option) -> Self { + Self { + status, + changed_at: Utc::now(), + reason, + } + } + + /// The status that was entered. + pub fn status(&self) -> &IntentStatus { + &self.status + } + + /// When the transition occurred. + pub fn changed_at(&self) -> DateTime { + self.changed_at + } + + /// Optional reason for the transition. + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } +} + +/// The entry point of every AI-assisted workflow. +/// +/// An `Intent` captures both the verbatim user input (`prompt`) and the +/// AI's structured understanding of that input (`content`). It is +/// created at step ② and completed at step ⑩ of the end-to-end flow. +/// See module documentation for lifecycle position, status transitions, +/// and conversational refinement. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Intent { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, - content: String, - parent_id: Option, - root_id: Option, - task_id: Option, - result_commit_sha: Option, - status: IntentStatus, + /// Verbatim natural-language request from the user. + /// + /// This is the unmodified input exactly as the user typed it (e.g. + /// "Add pagination to the user list API"). It is set once at + /// creation and never changed, preserving the original request for + /// auditing and potential re-analysis with a different model. + prompt: String, + /// AI-analyzed structured interpretation of `prompt`. + /// + /// `None` while the Intent is in `Draft` status — the AI has not + /// yet processed the prompt. Set to `Some(...)` when the AI + /// completes its analysis, at which point the status should + /// transition to `Active`. The content typically includes: + /// - Disambiguated requirements + /// - Identified scope (which files, modules, APIs are affected) + /// - Inferred constraints or acceptance criteria + /// + /// Unlike `prompt`, `content` is the AI's output and may be + /// regenerated if the analysis is re-run. + #[serde(default, skip_serializing_if = "Option::is_none")] + content: Option, + /// Link to a predecessor Intent for conversational refinement. + /// + /// Forms a singly-linked list from newest to oldest: each + /// follow-up Intent points to the Intent it refines. `None` for + /// the first Intent in a conversation. The orchestrator can walk + /// the `parent` chain to reconstruct the full conversational + /// history and provide prior context to the AI. + /// + /// Example chain: Intent₂ → Intent₁ → Intent₀ (root, parent=None). + #[serde(default, skip_serializing_if = "Option::is_none")] + parent: Option, + /// Git commit hash recorded when this Intent is fulfilled. + /// + /// Set by the orchestrator at step ⑩ after the + /// [`Decision`](super::decision::Decision) applies the final + /// PatchSet. `None` while the Intent is in progress (`Draft` or + /// `Active`) or if it was `Cancelled`. When set, the Intent's + /// status should be `Completed`. + #[serde(default, skip_serializing_if = "Option::is_none")] + commit: Option, + /// Link to the [`Plan`](super::plan::Plan) derived from this + /// Intent. + /// + /// Set after the AI analyzes `content` and produces a Plan at + /// step ③. Always points to the **latest** Plan revision — if + /// the Plan is revised (via `Plan.previous` chain), this field + /// is updated to the newest version. `None` while no Plan has + /// been created yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + plan: Option, + /// Append-only chronological history of status transitions. + /// + /// Initialized with a single `Draft` entry at creation. Each call + /// to [`set_status`](Intent::set_status) or + /// [`set_status_with_reason`](Intent::set_status_with_reason) + /// pushes a new [`StatusEntry`]. The current status is always + /// `statuses.last().status`. Entries are never removed or mutated. + /// + /// This design preserves the full transition timeline with + /// timestamps and optional reasons, enabling audit and duration + /// analysis (e.g. time spent in `Active` before `Completed`). + statuses: Vec, } impl Intent { - pub fn new( - repo_id: Uuid, - created_by: ActorRef, - content: impl Into, - ) -> Result { + /// Create a new intent in `Draft` status from a raw user prompt. + /// + /// The `content` field is initially `None` — call [`set_content`](Intent::set_content) + /// after the AI has analyzed the prompt. + pub fn new(created_by: ActorRef, prompt: impl Into) -> Result { Ok(Self { - header: Header::new(ObjectType::Intent, repo_id, created_by)?, - content: content.into(), - parent_id: None, - root_id: None, - task_id: None, - result_commit_sha: None, - status: IntentStatus::Draft, + header: Header::new(ObjectType::Intent, created_by)?, + prompt: prompt.into(), + content: None, + parent: None, + commit: None, + plan: None, + statuses: vec![StatusEntry::new(IntentStatus::Draft, None)], }) } + /// Returns a reference to the common header. pub fn header(&self) -> &Header { &self.header } - pub fn content(&self) -> &str { - &self.content + /// Returns the raw user prompt. + pub fn prompt(&self) -> &str { + &self.prompt } - pub fn parent_id(&self) -> Option { - self.parent_id + /// Returns the AI-analyzed content, if available. + pub fn content(&self) -> Option<&str> { + self.content.as_deref() } - pub fn root_id(&self) -> Option { - self.root_id + /// Sets the AI-analyzed content. + pub fn set_content(&mut self, content: Option) { + self.content = content; } - pub fn task_id(&self) -> Option { - self.task_id + /// Returns the parent intent ID, if this is part of a refinement chain. + pub fn parent(&self) -> Option { + self.parent } - pub fn result_commit_sha(&self) -> Option<&IntegrityHash> { - self.result_commit_sha.as_ref() + /// Returns the result commit SHA, if the intent has been fulfilled. + pub fn commit(&self) -> Option<&IntegrityHash> { + self.commit.as_ref() } - pub fn status(&self) -> &IntentStatus { - &self.status + /// Returns the current lifecycle status (the last entry in the history). + /// + /// Returns `None` only if `statuses` is empty, which should not + /// happen for objects created via [`Intent::new`] (seeds with + /// `Draft`), but may occur for malformed deserialized data. + pub fn status(&self) -> Option<&IntentStatus> { + self.statuses.last().map(|e| &e.status) } - pub fn set_parent_id(&mut self, parent_id: Option) { - self.parent_id = parent_id; + /// Returns the full chronological status history. + pub fn statuses(&self) -> &[StatusEntry] { + &self.statuses } - pub fn set_root_id(&mut self, root_id: Option) { - self.root_id = root_id; + /// Links this intent to a parent intent for conversational refinement. + pub fn set_parent(&mut self, parent: Option) { + self.parent = parent; } - pub fn set_task_id(&mut self, task_id: Option) { - self.task_id = task_id; + /// Records the git commit SHA that fulfilled this intent. + pub fn set_commit(&mut self, sha: Option) { + self.commit = sha; } - pub fn set_result_commit_sha(&mut self, sha: Option) { - self.result_commit_sha = sha; + /// Returns the associated Plan ID, if a Plan has been derived from this intent. + pub fn plan(&self) -> Option { + self.plan } + /// Associates this intent with a [`Plan`](super::plan::Plan). + pub fn set_plan(&mut self, plan: Option) { + self.plan = plan; + } + + /// Transitions the intent to a new lifecycle status, appending to the history. pub fn set_status(&mut self, status: IntentStatus) { - self.status = status; + self.statuses.push(StatusEntry::new(status, None)); + } + + /// Transitions the intent to a new lifecycle status with a reason. + pub fn set_status_with_reason(&mut self, status: IntentStatus, reason: impl Into) { + self.statuses + .push(StatusEntry::new(status, Some(reason.into()))); } } @@ -156,14 +396,58 @@ mod tests { #[test] fn test_intent_creation() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); - let intent = Intent::new(repo_id, actor, "Refactor login flow").expect("intent"); + let mut intent = Intent::new(actor, "Refactor login flow").expect("intent"); assert_eq!(intent.header().object_type(), &ObjectType::Intent); - assert_eq!(intent.status(), &IntentStatus::Draft); - assert!(intent.parent_id().is_none()); - assert!(intent.root_id().is_none()); - assert!(intent.task_id().is_none()); + assert_eq!(intent.prompt(), "Refactor login flow"); + assert!(intent.content().is_none()); + assert_eq!(intent.status(), Some(&IntentStatus::Draft)); + assert!(intent.parent().is_none()); + assert!(intent.plan().is_none()); + + intent.set_content(Some("Restructure the authentication module".to_string())); + assert_eq!( + intent.content(), + Some("Restructure the authentication module") + ); + + // After content is analyzed, a Plan can be linked + let plan_id = Uuid::from_u128(0x42); + intent.set_plan(Some(plan_id)); + assert_eq!(intent.plan(), Some(plan_id)); + } + + #[test] + fn test_statuses() { + let actor = ActorRef::human("jackie").expect("actor"); + let mut intent = Intent::new(actor, "Fix bug").expect("intent"); + + // Initial state: one Draft entry + assert_eq!(intent.statuses().len(), 1); + assert_eq!(intent.status(), Some(&IntentStatus::Draft)); + + // Transition to Active + intent.set_status(IntentStatus::Active); + assert_eq!(intent.status(), Some(&IntentStatus::Active)); + assert_eq!(intent.statuses().len(), 2); + + // Transition to Completed with reason + intent.set_status_with_reason(IntentStatus::Completed, "All tasks done"); + assert_eq!(intent.status(), Some(&IntentStatus::Completed)); + assert_eq!(intent.statuses().len(), 3); + + // Verify full history + let history = intent.statuses(); + assert_eq!(history[0].status(), &IntentStatus::Draft); + assert!(history[0].reason().is_none()); + assert_eq!(history[1].status(), &IntentStatus::Active); + assert!(history[1].reason().is_none()); + assert_eq!(history[2].status(), &IntentStatus::Completed); + assert_eq!(history[2].reason(), Some("All tasks done")); + + // Timestamps are ordered + assert!(history[1].changed_at() >= history[0].changed_at()); + assert!(history[2].changed_at() >= history[1].changed_at()); } } diff --git a/src/internal/object/mod.rs b/src/internal/object/mod.rs index e75d80e9..5d04c07b 100644 --- a/src/internal/object/mod.rs +++ b/src/internal/object/mod.rs @@ -1,5 +1,141 @@ -//! Object model definitions for Git blobs, trees, commits, tags, and supporting traits that let the -//! pack/zlib layers create strongly typed values from raw bytes. +//! Object model definitions for Git blobs, trees, commits, tags, and +//! supporting traits that let the pack/zlib layers create strongly typed +//! values from raw bytes. +//! +//! AI objects are also defined here, as they are a fundamental part of +//! the system and need to be accessible across multiple modules without +//! circular dependencies. +//! +//! # AI Object End-to-End Flow +//! +//! ```text +//! ① User input +//! │ +//! ▼ +//! ② Intent (Draft → Active) +//! │ +//! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame +//! │ +//! ▼ +//! ③ Plan (pipeline, fwindow, steps) +//! │ +//! ├─ PlanStep₀ (inline) +//! ├─ PlanStep₁ ──task──▶ sub-Task (recursive) +//! └─ PlanStep₂ (inline) +//! │ +//! ▼ +//! ④ Task (Draft → Running) +//! │ +//! ▼ +//! ⑤ Run (Created → Patching → Validating → Completed/Failed) +//! │ +//! ├──▶ Provenance (1:1, LLM config + token usage) +//! ├──▶ ContextSnapshot (optional, static context at start) +//! │ +//! │ ┌─── agent execution loop ───┐ +//! │ │ │ +//! │ │ ⑥ ToolInvocation (1:N) │ ← action log +//! │ │ │ │ +//! │ │ ▼ │ +//! │ │ ⑦ PatchSet (Proposed) │ ← candidate diff +//! │ │ │ │ +//! │ │ ▼ │ +//! │ │ ⑧ Evidence (1:N) │ ← test/lint/build +//! │ │ │ │ +//! │ │ ├─ pass ──────────────┘ +//! │ │ └─ fail → new PatchSet (retry within Run) +//! │ └─────────────────────────────┘ +//! │ +//! ▼ +//! ⑨ Decision (terminal verdict) +//! │ +//! ├─ Commit → apply PatchSet, record result_commit +//! ├─ Retry → create new Run ⑤ for same Task +//! ├─ Abandon → mark Task as Failed +//! ├─ Checkpoint → save state, resume later +//! └─ Rollback → revert applied PatchSet +//! │ +//! ▼ +//! ⑩ Intent (Completed) ← commit recorded +//! ``` +//! +//! ## Steps +//! +//! 1. **User input** — the user provides a natural-language request. +//! +//! 2. **[`Intent`](intent::Intent)** — captures the raw prompt and the +//! AI's structured interpretation. Status transitions from `Draft` +//! (prompt only) to `Active` (analysis complete). Supports +//! conversational refinement via `parent` chain. +//! +//! 3. **[`Plan`](plan::Plan)** — a sequence of +//! [`PlanStep`](plan::PlanStep)s derived from the Intent. References +//! a [`ContextPipeline`](pipeline::ContextPipeline) and records the +//! visible frame range (`fwindow`). Steps track consumed/produced +//! frames by stable ID (`iframes`/`oframes`). A step may spawn a sub-Task for +//! recursive decomposition. Plans form a revision chain via +//! `previous`. +//! +//! 4. **[`Task`](task::Task)** — a unit of work with title, constraints, +//! and acceptance criteria. May link back to its originating Intent. +//! Accumulates Runs in `runs` (chronological execution history). +//! +//! 5. **[`Run`](run::Run)** — a single execution attempt of a Task. +//! Records the baseline `commit`, the Plan version being executed +//! (snapshot reference), and the host `environment`. A +//! [`Provenance`](provenance::Provenance) (1:1) captures the LLM +//! configuration and token usage. +//! +//! 6. **[`ToolInvocation`](tool::ToolInvocation)** — the finest-grained +//! record: one per tool call (read file, run command, etc.). Forms +//! a chronological action log for the Run. Tracks file I/O via +//! `io_footprint`. +//! +//! 7. **[`PatchSet`](patchset::PatchSet)** — a candidate diff generated +//! by the agent. Contains the diff `artifact`, file-level `touched` +//! summary, and `rationale`. Starts as `Proposed`; transitions to +//! `Applied` or `Rejected`. Ordering is by position in +//! `Run.patchsets`. +//! +//! 8. **[`Evidence`](evidence::Evidence)** — output of a validation tool +//! (test, lint, build) run against a PatchSet. One per tool +//! invocation. Carries `exit_code`, `summary`, and +//! `report_artifacts`. Feeds into the Decision. +//! +//! 9. **[`Decision`](decision::Decision)** — the terminal verdict of a +//! Run. Selects a PatchSet to apply (`Commit`), retries the Task +//! (`Retry`), gives up (`Abandon`), saves progress (`Checkpoint`), +//! or reverts (`Rollback`). Records `rationale` and +//! `result_commit_sha`. +//! +//! 10. **Intent completed** — the orchestrator records the final git +//! commit in `Intent.commit` and transitions status to `Completed`. +//! +//! ## Object Relationship Summary +//! +//! | From | Field | To | Cardinality | +//! |------|-------|----|-------------| +//! | Intent | `parent` | Intent | 0..1 | +//! | Intent | `plan` | Plan | 0..1 | +//! | Plan | `previous` | Plan | 0..1 | +//! | Plan | `pipeline` | ContextPipeline | 0..1 | +//! | PlanStep | `task` | Task | 0..1 | +//! | Task | `parent` | Task | 0..1 | +//! | Task | `intent` | Intent | 0..1 | +//! | Task | `runs` | Run | 0..N | +//! | Task | `dependencies` | Task | 0..N | +//! | Run | `task` | Task | 1 | +//! | Run | `plan` | Plan | 0..1 | +//! | Run | `snapshot` | ContextSnapshot | 0..1 | +//! | Run | `patchsets` | PatchSet | 0..N | +//! | PatchSet | `run` | Run | 1 | +//! | Evidence | `run_id` | Run | 1 | +//! | Evidence | `patchset_id` | PatchSet | 0..1 | +//! | Decision | `run_id` | Run | 1 | +//! | Decision | `chosen_patchset_id` | PatchSet | 0..1 | +//! | Provenance | `run_id` | Run | 1 | +//! | ToolInvocation | `run_id` | Run | 1 | +//! pub mod blob; pub mod commit; pub mod context; @@ -9,6 +145,7 @@ pub mod integrity; pub mod intent; pub mod note; pub mod patchset; +pub mod pipeline; pub mod plan; pub mod provenance; pub mod run; diff --git a/src/internal/object/patchset.rs b/src/internal/object/patchset.rs index 5d8c3862..6f13719a 100644 --- a/src/internal/object/patchset.rs +++ b/src/internal/object/patchset.rs @@ -1,17 +1,69 @@ //! AI PatchSet Definition //! -//! A `PatchSet` represents a proposed set of code changes (diffs) generated by an agent. +//! A `PatchSet` represents a proposed set of code changes (diffs) generated +//! by an agent during a [`Run`](super::run::Run). It is the atomic unit of +//! code modification in the AI workflow — every change the agent wants to +//! make to the repository is packaged as a PatchSet. //! -//! # Generations +//! # Relationships //! -//! PatchSets are often created in generations (iterations). -//! If a PatchSet fails validation or code review, the agent may generate a new PatchSet (generation N+1) -//! for the same `Run`. +//! ```text +//! Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...] +//! │ +//! └──run──▶ Run (back-reference) +//! ``` +//! +//! - **Run** (bidirectional): `Run.patchsets` holds the forward reference +//! (chronological generation history), `PatchSet.run` is the back-reference. +//! +//! # Lifecycle +//! +//! ```text +//! ┌──────────┐ agent produces diff ┌──────────┐ +//! │ (created)│ ───────────────────────▶ │ Proposed │ +//! └──────────┘ └────┬─────┘ +//! │ +//! ┌───────────────────┼───────────────────┐ +//! │ validation/review │ │ +//! ▼ passes ▼ fails │ +//! ┌─────────┐ ┌──────────┐ │ +//! │ Applied │ │ Rejected │ │ +//! └─────────┘ └────┬─────┘ │ +//! │ │ +//! ▼ │ +//! agent generates new PatchSet │ +//! appended to Run.patchsets │ +//! ``` +//! +//! 1. **Creation**: The orchestrator calls `PatchSet::new()`, which sets +//! `apply_status` to `Proposed`. At this point `artifact` is `None` +//! and `touched` is empty. +//! 2. **Diff generation**: The agent produces a diff against `commit` +//! (the baseline Git commit). It sets `artifact` to point to the +//! stored diff content, populates `touched` with a file-level +//! summary, writes a `rationale`, and records the `format`. +//! 3. **Review / validation**: The orchestrator or a human reviewer +//! inspects the PatchSet. Automated checks (tests, linting) may run. +//! 4. **Applied**: If the diff passes, the orchestrator commits it to +//! the repository and transitions `apply_status` to `Applied`. +//! 5. **Rejected**: If the diff fails validation or is rejected by a +//! reviewer, `apply_status` becomes `Rejected`. The agent may then +//! generate a new PatchSet appended to `Run.patchsets`. +//! +//! # Ordering +//! +//! PatchSet ordering is determined by position in `Run.patchsets`. If a +//! PatchSet is rejected, the agent generates a new PatchSet and appends +//! it to the Vec. The last entry is always the most recent attempt. //! //! # Content //! -//! The actual diff content is stored as an `ArtifactRef` (e.g., pointing to a file in object storage), -//! while `TouchedFile` provides a lightweight summary for UI/indexing. +//! The actual diff content is stored as an [`ArtifactRef`] (via the +//! `artifact` field), while [`TouchedFile`] (via the `touched` field) +//! provides a lightweight file-level summary for UI and indexing. +//! The `format` field indicates how to parse the artifact content +//! (unified diff or git diff). The `rationale` field carries the +//! agent's explanation of what was changed and why. use std::fmt; @@ -38,8 +90,6 @@ pub enum ApplyStatus { Applied, /// Patch was rejected by validation or user. Rejected, - /// A newer generation has replaced this patch. - Superseded, } impl ApplyStatus { @@ -48,7 +98,6 @@ impl ApplyStatus { ApplyStatus::Proposed => "proposed", ApplyStatus::Applied => "applied", ApplyStatus::Rejected => "rejected", - ApplyStatus::Superseded => "superseded", } } } @@ -112,43 +161,101 @@ impl TouchedFile { } /// PatchSet object containing a candidate diff. -/// Each generation represents a new candidate diff for the same run. +/// +/// Ordering between PatchSets is determined by their position in +/// [`Run.patchsets`](super::run::Run). The PatchSet itself does not +/// carry a generation number or supersession list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PatchSet { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, - run_id: Uuid, - generation: u32, - base_commit_sha: IntegrityHash, - diff_format: DiffFormat, - diff_artifact: Option, - #[serde(default)] - touched_files: Vec, - #[serde(default)] - supersedes_patchset_ids: Vec, + /// The [`Run`](super::run::Run) that generated this PatchSet. + /// `Run.patchsets` holds the forward reference and ordering. + run: Uuid, + /// Git commit hash the diff is based on. + commit: IntegrityHash, + /// Diff format used for the patch content (e.g. unified diff, git diff). + /// + /// Determines how the diff stored in `artifact` should be parsed. + /// `UnifiedDiff` is the standard format produced by `diff -u`; + /// `GitDiff` extends it with binary file support, rename detection, + /// and mode-change headers. The orchestrator sets this at creation + /// time based on the tool that generated the diff. + #[serde(alias = "diff_format")] + format: DiffFormat, + /// Reference to the actual diff content in object storage. + /// + /// Points to an [`ArtifactRef`] whose payload contains the full + /// diff text (or binary patch) in the encoding described by `format`. + /// `None` while the diff is still being generated; set once the + /// agent finishes producing the patch. Consumers fetch the artifact, + /// then interpret it according to `format`. + #[serde(alias = "diff_artifact")] + artifact: Option, + /// Lightweight summary of files modified in this PatchSet. + /// + /// Each [`TouchedFile`] records a path, change type (add/modify/ + /// delete/rename/copy), and line-count deltas. This allows UIs and + /// indexing pipelines to display a file-level overview without + /// downloading or parsing the full diff artifact. The list is + /// populated incrementally as the agent produces changes and should + /// be consistent with the actual diff content. + #[serde(default, alias = "touched_files")] + touched: Vec, + /// Human-readable explanation of the changes in this PatchSet. + /// + /// Serves a role analogous to a commit message or PR description, + /// bridging the gap between the high-level goal (Task/Plan) and + /// the raw diff (artifact). + /// + /// **Primary author**: the agent executing the Run. After producing + /// the diff, the agent summarises **what was changed and why** and + /// writes it here. A human reviewer may later overwrite or refine + /// the text via `set_rationale()` if the agent's explanation is + /// insufficient. + /// + /// When a Run produces multiple PatchSets (successive attempts), + /// each rationale captures the reasoning behind that specific + /// attempt, e.g.: + /// + /// - PatchSet₀: "Replaced session auth with JWT — breaks backward compat" + /// - PatchSet₁: "Gradual migration: accept both auth schemes" + /// + /// `None` only when the PatchSet is still being generated or the + /// agent did not provide an explanation. Reviewers should treat a + /// missing rationale as a signal to inspect the diff more carefully. rationale: Option, + /// Current application status of this PatchSet. + /// + /// Tracks whether the diff has been applied to the repository: + /// + /// - **`Proposed`** (initial): The diff has been generated but not + /// yet committed. The orchestrator or a human reviewer can inspect + /// the artifact, run validation, and decide whether to apply. + /// - **`Applied`**: The diff has been committed to the repository. + /// Once applied, the PatchSet is immutable — further changes + /// require a new PatchSet in the same Run. + /// - **`Rejected`**: The diff was rejected by automated validation + /// (e.g. tests failed) or by a human reviewer. The agent may + /// generate a new PatchSet appended to `Run.patchsets` to retry. + /// + /// Transitions: `Proposed → Applied` or `Proposed → Rejected`. + /// No other transitions are valid. apply_status: ApplyStatus, } impl PatchSet { - /// Create a new patchset object - pub fn new( - repo_id: Uuid, - created_by: ActorRef, - run_id: Uuid, - base_commit_sha: impl AsRef, - generation: u32, - ) -> Result { - let base_commit_sha = base_commit_sha.as_ref().parse()?; + /// Create a new patchset object. + pub fn new(created_by: ActorRef, run: Uuid, commit: impl AsRef) -> Result { + let commit = commit.as_ref().parse()?; Ok(Self { - header: Header::new(ObjectType::PatchSet, repo_id, created_by)?, - run_id, - generation, - base_commit_sha, - diff_format: DiffFormat::UnifiedDiff, - diff_artifact: None, - touched_files: Vec::new(), - supersedes_patchset_ids: Vec::new(), + header: Header::new(ObjectType::PatchSet, created_by)?, + run, + commit, + format: DiffFormat::UnifiedDiff, + artifact: None, + touched: Vec::new(), rationale: None, apply_status: ApplyStatus::Proposed, }) @@ -158,32 +265,24 @@ impl PatchSet { &self.header } - pub fn run_id(&self) -> Uuid { - self.run_id - } - - pub fn generation(&self) -> u32 { - self.generation + pub fn run(&self) -> Uuid { + self.run } - pub fn base_commit_sha(&self) -> &IntegrityHash { - &self.base_commit_sha + pub fn commit(&self) -> &IntegrityHash { + &self.commit } - pub fn diff_format(&self) -> &DiffFormat { - &self.diff_format + pub fn format(&self) -> &DiffFormat { + &self.format } - pub fn diff_artifact(&self) -> Option<&ArtifactRef> { - self.diff_artifact.as_ref() + pub fn artifact(&self) -> Option<&ArtifactRef> { + self.artifact.as_ref() } - pub fn touched_files(&self) -> &[TouchedFile] { - &self.touched_files - } - - pub fn supersedes_patchset_ids(&self) -> &[Uuid] { - &self.supersedes_patchset_ids + pub fn touched(&self) -> &[TouchedFile] { + &self.touched } pub fn rationale(&self) -> Option<&str> { @@ -194,12 +293,12 @@ impl PatchSet { &self.apply_status } - pub fn set_diff_artifact(&mut self, diff_artifact: Option) { - self.diff_artifact = diff_artifact; + pub fn set_artifact(&mut self, artifact: Option) { + self.artifact = artifact; } - pub fn add_touched_file(&mut self, file: TouchedFile) { - self.touched_files.push(file); + pub fn add_touched(&mut self, file: TouchedFile) { + self.touched.push(file); } pub fn set_rationale(&mut self, rationale: Option) { @@ -209,26 +308,6 @@ impl PatchSet { pub fn set_apply_status(&mut self, apply_status: ApplyStatus) { self.apply_status = apply_status; } - - pub fn add_supersedes_patchset_id(&mut self, patchset_id: Uuid) { - self.supersedes_patchset_ids.push(patchset_id); - } - - pub fn set_supersedes_patchset_ids(&mut self, patchset_ids: Vec) { - self.supersedes_patchset_ids = patchset_ids; - } - - pub fn validate_supersedes(&self) -> Result<(), GitError> { - if self - .supersedes_patchset_ids - .contains(&self.header.object_id()) - { - return Err(GitError::InvalidPatchSetObject( - "PatchSet cannot supersede itself".to_string(), - )); - } - Ok(()) - } } impl fmt::Display for PatchSet { @@ -274,40 +353,16 @@ mod tests { #[test] fn test_patchset_creation() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::agent("test-agent").expect("actor"); - let run_id = Uuid::from_u128(0x1); + let run = Uuid::from_u128(0x1); let base_hash = test_hash_hex(); - let patchset = PatchSet::new(repo_id, actor, run_id, &base_hash, 1).expect("patchset"); + let patchset = PatchSet::new(actor, run, &base_hash).expect("patchset"); assert_eq!(patchset.header().object_type(), &ObjectType::PatchSet); - assert_eq!(patchset.generation(), 1); - assert_eq!(patchset.diff_format(), &DiffFormat::UnifiedDiff); + assert_eq!(patchset.run(), run); + assert_eq!(patchset.format(), &DiffFormat::UnifiedDiff); assert_eq!(patchset.apply_status(), &ApplyStatus::Proposed); - assert!(patchset.touched_files().is_empty()); - assert!(patchset.supersedes_patchset_ids().is_empty()); - } - - #[test] - fn test_patchset_validate_supersedes_self_reference() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); - let actor = ActorRef::agent("test-agent").expect("actor"); - let run_id = Uuid::from_u128(0x1); - let base_hash = test_hash_hex(); - - let mut patchset = PatchSet::new(repo_id, actor, run_id, &base_hash, 1).expect("patchset"); - let self_id = patchset.header().object_id(); - patchset.add_supersedes_patchset_id(self_id); - - let err = patchset - .validate_supersedes() - .expect_err("should be invalid"); - match err { - GitError::InvalidPatchSetObject(msg) => { - assert!(msg.contains("supersede"), "unexpected message: {msg}"); - } - other => panic!("unexpected error: {other}"), - } + assert!(patchset.touched().is_empty()); } } diff --git a/src/internal/object/pipeline.rs b/src/internal/object/pipeline.rs new file mode 100644 index 00000000..94c23d70 --- /dev/null +++ b/src/internal/object/pipeline.rs @@ -0,0 +1,586 @@ +//! Dynamic Context Pipeline +//! +//! A [`ContextPipeline`] solves the context-forgetting problem in +//! long-running AI tasks. Instead of relying solely on a static +//! [`ContextSnapshot`](super::context::ContextSnapshot) captured at +//! Run start, a ContextPipeline accumulates incremental +//! [`ContextFrame`]s throughout the workflow. +//! +//! # Position in Lifecycle +//! +//! ```text +//! ② Intent (Active) ← content analyzed +//! │ +//! ▼ +//! ContextPipeline created ← seeded with IntentAnalysis frame +//! │ +//! ▼ +//! ③ Plan (Plan.pipeline → Pipeline, Plan.fwindow = visible range) +//! │ steps execute +//! ▼ +//! Frames accumulate ← StepSummary, CodeChange, ToolCall, ... +//! │ +//! ▼ +//! Replan? → new Plan with updated fwindow +//! ``` +//! +//! The pipeline is created *after* an Intent's content is analyzed +//! (step ②) but *before* a Plan exists. The initial +//! [`IntentAnalysis`](FrameKind::IntentAnalysis) frame captures the +//! AI's structured interpretation, which serves as the foundation +//! for Plan creation. The [`Plan`](super::plan::Plan) then references +//! this pipeline via `pipeline` and records the visible frame range +//! via `fwindow`. During execution, frames accumulate to track +//! step-by-step progress. +//! +//! # Relationship to Other Objects +//! +//! ```text +//! Intent ──plan──→ Plan ──pipeline──→ ContextPipeline +//! │ │ +//! [PlanStep₀, ...] [IntentAnalysis, StepSummary, ...] +//! │ ▲ +//! iframes/oframes ──────────────┘ +//! ``` +//! +//! | From | Field | To | Notes | +//! |------|-------|----|-------| +//! | Plan | `pipeline` | ContextPipeline | 0..1 | +//! | PlanStep | `iframes` | ContextFrame IDs | consumed context | +//! | PlanStep | `oframes` | ContextFrame IDs | produced context | +//! +//! The pipeline itself has no back-references — it is a passive +//! container. [`PlanStep`](super::plan::PlanStep)s own the +//! association via `iframes` and `oframes`. +//! +//! # Eviction +//! +//! When `max_frames > 0` and the limit is exceeded, the oldest +//! evictable frame is removed. `IntentAnalysis` and `Checkpoint` +//! frames are **protected** from eviction — they always survive. +//! +//! # Purpose +//! +//! - **Context Continuity**: Maintains a rolling window of high-value +//! context for the agent's working memory across Plan steps. +//! - **Incremental Updates**: Unlike the static ContextSnapshot, the +//! pipeline grows as work progresses, capturing step summaries, +//! code changes, and tool results. +//! - **Bounded Memory**: `max_frames` + eviction ensures the pipeline +//! doesn't grow unboundedly in long-running workflows. +//! - **Replan Support**: When replanning occurs, a new Plan can +//! reference the same pipeline with an updated `fwindow` that +//! includes frames accumulated since the previous plan. + +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::GitError, + hash::ObjectHash, + internal::object::{ + ObjectTrait, + types::{ActorRef, Header, ObjectType}, + }, +}; + +/// The kind of context captured in a [`ContextFrame`]. +/// +/// Determines how the frame's `summary` and `data` should be +/// interpreted. `IntentAnalysis` and `Checkpoint` are protected +/// from eviction when `max_frames` is exceeded. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FrameKind { + /// Initial context derived from an Intent's analyzed content. + /// + /// Created when the AI fills in the `content` field on an Intent, + /// serving as the foundation for subsequent Plan creation. This + /// is the **seed frame** — always the first frame in a pipeline. + /// **Protected from eviction.** + IntentAnalysis, + /// Summary produced after a [`PlanStep`](super::plan::PlanStep) + /// completes. Captures what the step accomplished so that + /// subsequent steps have context. + StepSummary, + /// Code change digest (e.g. files modified, diff stats). + /// Typically produced alongside a + /// [`PatchSet`](super::patchset::PatchSet). + CodeChange, + /// System or environment state snapshot (e.g. memory usage, + /// disk space, running services). + SystemState, + /// Context captured during error recovery. Records what went + /// wrong and what corrective action was taken, so that subsequent + /// steps don't repeat the same mistakes. + ErrorRecovery, + /// Explicit save-point created by user or system. + /// **Protected from eviction.** Used for long-running workflows + /// where the agent may be paused and resumed. + Checkpoint, + /// Result of an external tool invocation (MCP service, function + /// call, REST API, CLI command, etc.). + /// + /// Intentionally protocol-agnostic: MCP is one transport for + /// tool calls, but agents may also invoke tools via direct + /// function calls, HTTP APIs, or shell commands. Protocol-specific + /// details (server name, tool name, arguments, result preview) + /// belong in `ContextFrame.data`. + ToolCall, + /// Application-defined context type not covered by the variants + /// above. + Other(String), +} + +impl FrameKind { + pub fn as_str(&self) -> &str { + match self { + FrameKind::IntentAnalysis => "intent_analysis", + FrameKind::StepSummary => "step_summary", + FrameKind::CodeChange => "code_change", + FrameKind::SystemState => "system_state", + FrameKind::ErrorRecovery => "error_recovery", + FrameKind::Checkpoint => "checkpoint", + FrameKind::ToolCall => "tool_call", + FrameKind::Other(s) => s.as_str(), + } + } +} + +impl fmt::Display for FrameKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// A single context frame — a compact summary captured at a point in +/// time during the AI workflow. +/// +/// Frames are **passive data records**. They carry no back-references +/// to the [`PlanStep`](super::plan::PlanStep) that consumed or produced +/// them; that association is tracked on the step side via `iframes` +/// and `oframes`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextFrame { + /// Stable monotonic identifier for this frame. + /// + /// Assigned by [`ContextPipeline::push_frame`] from a monotonic + /// counter (`next_frame_id`). Unlike Vec indices, frame IDs remain + /// stable across eviction — [`PlanStep`](super::plan::PlanStep)s + /// reference frames by ID via `iframes` and `oframes`. + frame_id: u64, + /// The kind of context this frame captures. + /// + /// Determines how `summary` and `data` should be interpreted. + /// Also affects eviction: `IntentAnalysis` and `Checkpoint` + /// frames are protected. + kind: FrameKind, + /// Compact human-readable summary of this frame's content. + /// + /// Should be concise (a few sentences). For example: + /// - IntentAnalysis: "Add pagination to GET /users with limit/offset" + /// - StepSummary: "Refactored auth module, 3 files changed" + /// - CodeChange: "Modified src/api.rs (+42 -15)" + summary: String, + /// Structured data payload for machine consumption. + /// + /// Schema depends on `kind`. For example: + /// - CodeChange: `{"files": ["src/api.rs"], "insertions": 42, "deletions": 15}` + /// - ToolCall: `{"tool": "search", "args": {...}, "result_preview": "..."}` + /// + /// `None` when the `summary` is sufficient and no structured + /// data is needed. + #[serde(default, skip_serializing_if = "Option::is_none")] + data: Option, + /// UTC timestamp of when this frame was created. + /// + /// Automatically set to `Utc::now()` by [`ContextFrame::new`]. + /// Frames within a pipeline are chronologically ordered. + created_at: DateTime, + /// Estimated token count for context-window budgeting. + /// + /// Used by the orchestrator to decide how many frames fit in + /// the LLM's context window. `None` when the estimate hasn't + /// been computed. See + /// [`ContextPipeline::total_token_estimate`] for aggregation. + #[serde(default, skip_serializing_if = "Option::is_none")] + token_estimate: Option, +} + +impl ContextFrame { + /// Create a new frame with the given kind and summary. + /// + /// `frame_id` is typically assigned by + /// [`ContextPipeline::push_frame`]; callers building frames + /// manually can pass any unique monotonic value. + pub fn new(frame_id: u64, kind: FrameKind, summary: impl Into) -> Self { + Self { + frame_id, + kind, + summary: summary.into(), + data: None, + created_at: Utc::now(), + token_estimate: None, + } + } + + /// Returns this frame's stable ID. + pub fn frame_id(&self) -> u64 { + self.frame_id + } + + pub fn kind(&self) -> &FrameKind { + &self.kind + } + + pub fn summary(&self) -> &str { + &self.summary + } + + pub fn data(&self) -> Option<&serde_json::Value> { + self.data.as_ref() + } + + pub fn created_at(&self) -> DateTime { + self.created_at + } + + pub fn token_estimate(&self) -> Option { + self.token_estimate + } + + pub fn set_data(&mut self, data: Option) { + self.data = data; + } + + pub fn set_token_estimate(&mut self, token_estimate: Option) { + self.token_estimate = token_estimate; + } +} + +/// A dynamic context pipeline that accumulates +/// [`ContextFrame`]s throughout an AI workflow. +/// +/// Created when an [`Intent`](super::intent::Intent)'s content is +/// first analyzed, seeded with an +/// [`IntentAnalysis`](FrameKind::IntentAnalysis) frame. The +/// [`Plan`](super::plan::Plan) references this pipeline via +/// `pipeline` as its context basis. See module documentation for +/// lifecycle position, eviction rules, and purpose. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextPipeline { + /// Common header (object ID, type, timestamps, creator, etc.). + #[serde(flatten)] + header: Header, + /// Chronologically ordered context frames. + /// + /// New frames are appended via [`push_frame`](ContextPipeline::push_frame). + /// If `max_frames > 0` and the limit is exceeded, the oldest + /// evictable frame is removed (see eviction rules in module docs). + /// [`PlanStep`](super::plan::PlanStep)s reference frames by stable + /// `frame_id` via `iframes` and `oframes`. Frame IDs are monotonic + /// and survive eviction (indices do not). + #[serde(default)] + frames: Vec, + /// Monotonic counter for assigning stable [`ContextFrame::frame_id`]s. + /// + /// Incremented by [`push_frame`](ContextPipeline::push_frame) each + /// time a frame is added. Never decremented, even after eviction. + #[serde(default)] + next_frame_id: u64, + /// Maximum number of active frames before eviction kicks in. + /// + /// `0` means unlimited (no eviction). When the frame count + /// exceeds this limit, the oldest non-protected frame is removed. + /// `IntentAnalysis` and `Checkpoint` frames are protected and + /// never evicted. + #[serde(default)] + max_frames: u32, + /// Aggregated human-readable summary across all frames. + /// + /// Maintained by the orchestrator as a high-level overview of + /// the pipeline's accumulated context. Useful for quickly + /// understanding the overall progress without reading individual + /// frames. `None` when no summary has been set. + #[serde(default, skip_serializing_if = "Option::is_none")] + global_summary: Option, +} + +impl ContextPipeline { + /// Create a new empty pipeline. + /// + /// After creation, seed it with an [`IntentAnalysis`](FrameKind::IntentAnalysis) + /// frame, then create a [`Plan`](super::plan::Plan) that references this + /// pipeline via `pipeline`. + pub fn new(created_by: ActorRef) -> Result { + Ok(Self { + header: Header::new(ObjectType::ContextPipeline, created_by)?, + frames: Vec::new(), + next_frame_id: 0, + max_frames: 0, + global_summary: None, + }) + } + + pub fn header(&self) -> &Header { + &self.header + } + + /// Returns all frames in chronological order. + pub fn frames(&self) -> &[ContextFrame] { + &self.frames + } + + pub fn max_frames(&self) -> u32 { + self.max_frames + } + + pub fn global_summary(&self) -> Option<&str> { + self.global_summary.as_deref() + } + + pub fn set_max_frames(&mut self, max_frames: u32) { + self.max_frames = max_frames; + } + + pub fn set_global_summary(&mut self, summary: Option) { + self.global_summary = summary; + } + + /// Append a frame, assigning it a stable `frame_id`. + /// + /// Returns the assigned `frame_id`. If `max_frames > 0` and the + /// limit is exceeded, the oldest evictable (non-IntentAnalysis, + /// non-Checkpoint) frame is removed to make room. Eviction does + /// not affect the IDs of surviving frames. + pub fn push_frame(&mut self, kind: FrameKind, summary: impl Into) -> u64 { + let frame = ContextFrame::new(self.next_frame_id, kind, summary); + self.push_frame_raw(frame) + } + + /// Append a pre-built frame, overwriting its `frame_id` with the + /// next monotonic ID. Returns the assigned `frame_id`. + /// + /// Use this when you need to set properties (e.g. `token_estimate`, + /// `data`) on the frame before pushing. + pub fn push_frame_raw(&mut self, mut frame: ContextFrame) -> u64 { + let id = self.next_frame_id; + self.next_frame_id += 1; + frame.frame_id = id; + self.frames.push(frame); + self.evict_if_needed(); + id + } + + /// Look up a frame by its stable `frame_id`. + /// + /// Returns `None` if the frame has been evicted or the ID is invalid. + pub fn frame_by_id(&self, frame_id: u64) -> Option<&ContextFrame> { + self.frames.iter().find(|f| f.frame_id == frame_id) + } + + /// Returns frames that contribute to the active context window + /// (i.e. all current frames after any eviction has been applied). + pub fn active_frames(&self) -> &[ContextFrame] { + &self.frames + } + + /// Total estimated tokens across all frames. + pub fn total_token_estimate(&self) -> u64 { + self.frames.iter().filter_map(|f| f.token_estimate).sum() + } + + /// Evict the oldest evictable frame if over the limit. + /// + /// `IntentAnalysis` and `Checkpoint` frames are protected from eviction. + fn evict_if_needed(&mut self) { + if self.max_frames == 0 { + return; + } + while self.frames.len() > self.max_frames as usize { + // Find the first evictable frame (not IntentAnalysis or Checkpoint) + if let Some(pos) = self.frames.iter().position(|f| { + f.kind != FrameKind::Checkpoint && f.kind != FrameKind::IntentAnalysis + }) { + self.frames.remove(pos); + } else { + // All frames are protected — nothing to evict + break; + } + } + } +} + +impl fmt::Display for ContextPipeline { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ContextPipeline: {}", self.header.object_id()) + } +} + +impl ObjectTrait for ContextPipeline { + fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result + where + Self: Sized, + { + serde_json::from_slice(data) + .map_err(|e| GitError::InvalidContextPipelineObject(e.to_string())) + } + + fn get_type(&self) -> ObjectType { + ObjectType::ContextPipeline + } + + fn get_size(&self) -> usize { + match serde_json::to_vec(self) { + Ok(v) => v.len(), + Err(e) => { + tracing::warn!("failed to compute ContextPipeline size: {}", e); + 0 + } + } + } + + fn to_data(&self) -> Result, GitError> { + serde_json::to_vec(self).map_err(|e| GitError::InvalidContextPipelineObject(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_pipeline() -> ContextPipeline { + let actor = ActorRef::agent("orchestrator").expect("actor"); + ContextPipeline::new(actor).expect("pipeline") + } + + #[test] + fn test_pipeline_creation() { + let pipeline = make_pipeline(); + + assert_eq!( + pipeline.header().object_type(), + &ObjectType::ContextPipeline + ); + assert!(pipeline.frames().is_empty()); + assert_eq!(pipeline.max_frames(), 0); + assert!(pipeline.global_summary().is_none()); + } + + #[test] + fn test_push_and_retrieve_frames() { + let mut pipeline = make_pipeline(); + + let mut f1 = ContextFrame::new(0, FrameKind::StepSummary, "Completed auth refactor"); + f1.set_token_estimate(Some(200)); + let id0 = pipeline.push_frame_raw(f1); + + let id1 = pipeline.push_frame(FrameKind::CodeChange, "Modified 3 files, +120 -45 lines"); + + let mut f3 = ContextFrame::new(0, FrameKind::Checkpoint, "User save-point"); + f3.set_data(Some(serde_json::json!({"key": "value"}))); + let id2 = pipeline.push_frame_raw(f3); + + assert_eq!(pipeline.frames().len(), 3); + assert_eq!(id0, 0); + assert_eq!(id1, 1); + assert_eq!(id2, 2); + assert_eq!( + pipeline.frame_by_id(0).unwrap().kind(), + &FrameKind::StepSummary + ); + assert_eq!( + pipeline.frame_by_id(1).unwrap().kind(), + &FrameKind::CodeChange + ); + assert_eq!( + pipeline.frame_by_id(2).unwrap().kind(), + &FrameKind::Checkpoint + ); + assert!(pipeline.frame_by_id(2).unwrap().data().is_some()); + } + + #[test] + fn test_max_frames_eviction() { + let mut pipeline = make_pipeline(); + pipeline.set_max_frames(3); + + // Push a checkpoint (should survive eviction) + let cp_id = pipeline.push_frame(FrameKind::Checkpoint, "save-point"); + // Push regular frames + let s1_id = pipeline.push_frame(FrameKind::StepSummary, "step 1"); + pipeline.push_frame(FrameKind::StepSummary, "step 2"); + assert_eq!(pipeline.frames().len(), 3); + + // This push exceeds max_frames → oldest non-Checkpoint ("step 1") is evicted + pipeline.push_frame(FrameKind::CodeChange, "code change"); + assert_eq!(pipeline.frames().len(), 3); + + // Checkpoint survived, "step 1" was evicted + assert!(pipeline.frame_by_id(cp_id).is_some()); + assert!(pipeline.frame_by_id(s1_id).is_none()); // evicted + assert_eq!(pipeline.frames()[0].kind(), &FrameKind::Checkpoint); + assert_eq!(pipeline.frames()[1].summary(), "step 2"); + assert_eq!(pipeline.frames()[2].summary(), "code change"); + } + + #[test] + fn test_total_token_estimate() { + let mut pipeline = make_pipeline(); + + let mut f1 = ContextFrame::new(0, FrameKind::StepSummary, "s1"); + f1.set_token_estimate(Some(100)); + pipeline.push_frame_raw(f1); + + let mut f2 = ContextFrame::new(0, FrameKind::StepSummary, "s2"); + f2.set_token_estimate(Some(250)); + pipeline.push_frame_raw(f2); + + // Frame without token estimate + pipeline.push_frame(FrameKind::Checkpoint, "cp"); + + assert_eq!(pipeline.total_token_estimate(), 350); + } + + #[test] + fn test_serialization_roundtrip() { + let mut pipeline = make_pipeline(); + pipeline.set_global_summary(Some("Overall progress summary".to_string())); + + let mut frame = ContextFrame::new(0, FrameKind::StepSummary, "did stuff"); + frame.set_token_estimate(Some(150)); + frame.set_data(Some(serde_json::json!({"files": ["a.rs", "b.rs"]}))); + pipeline.push_frame_raw(frame); + + let data = pipeline.to_data().expect("serialize"); + let restored = + ContextPipeline::from_bytes(&data, ObjectHash::default()).expect("deserialize"); + + assert_eq!(restored.frames().len(), 1); + assert_eq!(restored.frames()[0].frame_id(), 0); + assert_eq!(restored.frames()[0].summary(), "did stuff"); + assert_eq!(restored.frames()[0].token_estimate(), Some(150)); + assert_eq!(restored.global_summary(), Some("Overall progress summary")); + } + + #[test] + fn test_intent_analysis_frame_survives_eviction() { + let mut pipeline = make_pipeline(); + pipeline.set_max_frames(2); + + // Seed with IntentAnalysis (protected) + let ia_id = pipeline.push_frame(FrameKind::IntentAnalysis, "AI analysis of user intent"); + let s1_id = pipeline.push_frame(FrameKind::StepSummary, "step 1"); + assert_eq!(pipeline.frames().len(), 2); + + // Adding another frame should evict "step 1", not IntentAnalysis + pipeline.push_frame(FrameKind::CodeChange, "code change"); + assert_eq!(pipeline.frames().len(), 2); + assert!(pipeline.frame_by_id(ia_id).is_some()); + assert!(pipeline.frame_by_id(s1_id).is_none()); // evicted + assert_eq!(pipeline.frames()[0].kind(), &FrameKind::IntentAnalysis); + assert_eq!(pipeline.frames()[1].summary(), "code change"); + } +} diff --git a/src/internal/object/plan.rs b/src/internal/object/plan.rs index 0fe92b96..1b0a7400 100644 --- a/src/internal/object/plan.rs +++ b/src/internal/object/plan.rs @@ -1,19 +1,138 @@ //! AI Plan Definition //! -//! A `Plan` represents a sequence of steps that an agent intends to execute to complete a task. +//! A [`Plan`] is a sequence of [`PlanStep`]s derived from an +//! [`Intent`](super::intent::Intent)'s analyzed content. It defines +//! *what* to do — the strategy and decomposition — while +//! [`Run`](super::run::Run) handles *how* to execute it. The Plan is +//! step ③ in the end-to-end flow described in [`mod.rs`](super). //! -//! # Versioning +//! # Position in Lifecycle //! -//! Plans are versioned monotonically. As the agent learns more or encounters obstacles, -//! it may update the plan. Each update creates a new `Plan` object with `plan_version = previous + 1`. +//! ```text +//! ② Intent (Active) ← content analyzed +//! │ +//! ├──▶ ContextPipeline ← seeded with IntentAnalysis frame +//! │ +//! ▼ +//! ③ Plan (pipeline, fwindow, steps) +//! │ +//! ├─ PlanStep₀ (inline) +//! ├─ PlanStep₁ ──task──▶ sub-Task (recursive) +//! └─ PlanStep₂ (inline) +//! │ +//! ▼ +//! ④ Task ──runs──▶ Run ──plan──▶ Plan (snapshot reference) +//! ``` +//! +//! # Revision Chain +//! +//! When the agent encounters obstacles or learns new information, it +//! creates a revised Plan via [`new_revision`](Plan::new_revision). +//! Each revision links back to its predecessor via `previous`, forming +//! a singly-linked revision chain. The [`Intent`](super::intent::Intent) +//! always points to the **latest** revision: +//! +//! ```text +//! Intent.plan ──▶ Plan_v3 (latest) +//! │ previous +//! ▼ +//! Plan_v2 +//! │ previous +//! ▼ +//! Plan_v1 (original, previous = None) +//! ``` +//! +//! Each [`Run`](super::run::Run) records the specific Plan version it +//! executed via a **snapshot reference** (`Run.plan`), which never +//! changes after creation. +//! +//! # Context Range +//! +//! A Plan references a [`ContextPipeline`](super::pipeline::ContextPipeline) +//! via `pipeline` and records the visible frame range `fwindow = (start, +//! end)` — the half-open range `[start..end)` of frames that were +//! visible when this Plan was created. This enables retrospective +//! analysis: given the context the agent saw, was the plan a reasonable +//! decomposition? +//! +//! ```text +//! ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅, ...] +//! ^^^^^^^^^^^^^^^^ +//! fwindow = (0, 4) +//! ``` +//! +//! When replanning occurs, a new Plan is created with an updated frame +//! range that includes frames accumulated since the previous plan. //! //! # Steps //! -//! Each step has an `intent` (what to do) and a `status` (pending/in_progress/done). -//! Steps can also define expected inputs/outputs for better chain-of-thought tracking. +//! Each [`PlanStep`] has a `description` (what to do) and a status +//! history (`statuses`) tracking every lifecycle transition with +//! timestamps and optional reasons, following the same append-only +//! pattern used by [`Intent`](super::intent::Intent). +//! +//! ## Step Context Tracking +//! +//! Each step tracks its relationship to pipeline frames via two +//! ID vectors: +//! +//! - `iframes` — stable `frame_id`s of frames the step **consumed** +//! as context. +//! - `oframes` — stable `frame_id`s of frames the step **produced** +//! (e.g. `StepSummary`, `CodeChange`). +//! +//! Frame IDs are monotonic integers assigned by +//! [`ContextPipeline::push_frame`](super::pipeline::ContextPipeline::push_frame). +//! Unlike Vec indices, IDs survive eviction — a step's `iframes` +//! remain valid even after older frames are evicted from the pipeline. +//! Look up frames via +//! [`ContextPipeline::frame_by_id`](super::pipeline::ContextPipeline::frame_by_id). +//! +//! All context association is owned by the step side; +//! [`ContextFrame`](super::pipeline::ContextFrame) itself is a passive +//! data record with no back-references. +//! +//! ```text +//! ContextPipeline.frames: [F₀, F₁, F₂, F₃, F₄, F₅] +//! │ │ ▲ +//! ╰────╯ │ +//! iframes=[0,1] oframes=[4] +//! ╰── Step₀ ──╯ +//! ``` +//! +//! ## Recursive Decomposition +//! +//! A step can optionally spawn a sub-[`Task`](super::task::Task) via +//! its `task` field. When set, the step delegates execution to an +//! independent Task with its own Run / Intent / Plan lifecycle, +//! enabling recursive work breakdown: +//! +//! ```text +//! Plan +//! ├─ Step₀ (inline — executed by current Run) +//! ├─ Step₁ ──task──▶ Task₁ +//! │ └─ Run → Plan +//! │ ├─ Step₁₋₀ +//! │ └─ Step₁₋₁ +//! └─ Step₂ (inline) +//! ``` +//! +//! # Purpose +//! +//! - **Decomposition**: Breaks a complex Intent into manageable, +//! ordered steps that an agent can execute sequentially. +//! - **Context Scoping**: `pipeline` + `fwindow` record exactly what +//! context the Plan was derived from. Step-level `iframes`/`oframes` +//! track fine-grained context flow. +//! - **Versioning**: The `previous` revision chain preserves the full +//! planning history, enabling comparison of strategies across +//! attempts. +//! - **Recursive Delegation**: Steps can spawn sub-Tasks for complex +//! sub-problems, enabling divide-and-conquer workflows. use std::fmt; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -26,101 +145,345 @@ use crate::{ }, }; -/// Plan step status. +/// Lifecycle status of a [`PlanStep`]. +/// +/// Valid transitions: +/// ```text +/// Pending ──▶ Progressing ──▶ Completed +/// │ │ +/// ├─────────────┴──▶ Failed +/// └──────────────────▶ Skipped +/// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum PlanStatus { - /// Step is waiting to be executed. +pub enum StepStatus { + /// Step is waiting to be executed. Initial state. Pending, - /// Step is currently being executed. - InProgress, - /// Step finished successfully. + /// Step is currently being executed by the agent. + Progressing, + /// Step finished successfully. Outputs and `oframes` should be set. Completed, - /// Step failed. + /// Step encountered an unrecoverable error. A reason should be + /// recorded in the [`StepStatusEntry`] that carries this status. Failed, - /// Step was skipped (e.g. no longer necessary). + /// Step was skipped (e.g. no longer necessary after replanning, + /// or pre-condition not met). Not an error — the Plan continues. Skipped, } -impl PlanStatus { +impl StepStatus { pub fn as_str(&self) -> &'static str { match self { - PlanStatus::Pending => "pending", - PlanStatus::InProgress => "in_progress", - PlanStatus::Completed => "completed", - PlanStatus::Failed => "failed", - PlanStatus::Skipped => "skipped", + StepStatus::Pending => "pending", + StepStatus::Progressing => "progressing", + StepStatus::Completed => "completed", + StepStatus::Failed => "failed", + StepStatus::Skipped => "skipped", } } } -impl fmt::Display for PlanStatus { +impl fmt::Display for StepStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } -/// Plan step with inputs, outputs, and checks. +/// A single entry in a step's status history. +/// +/// Mirrors [`StatusEntry`](super::intent::StatusEntry) in Intent. +/// Each transition appends a new entry; entries are never removed +/// or mutated, forming an append-only audit log. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StepStatusEntry { + /// The [`StepStatus`] that was entered by this transition. + status: StepStatus, + /// UTC timestamp of when this transition occurred. + changed_at: DateTime, + /// Optional human-readable reason for the transition. + /// + /// Recommended for `Failed` (error details) and `Skipped` + /// (why the step was deemed unnecessary). + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, +} + +impl StepStatusEntry { + pub fn new(status: StepStatus, reason: Option) -> Self { + Self { + status, + changed_at: Utc::now(), + reason, + } + } + + pub fn status(&self) -> &StepStatus { + &self.status + } + + pub fn changed_at(&self) -> DateTime { + self.changed_at + } + + pub fn reason(&self) -> Option<&str> { + self.reason.as_deref() + } +} + +/// Default for [`PlanStep::statuses`] when deserializing legacy data +/// that lacks the `statuses` field. +fn default_step_statuses() -> Vec { + vec![StepStatusEntry::new(StepStatus::Pending, None)] +} + +/// A single step within a [`Plan`], describing one unit of work. +/// +/// Steps are executed in order by the agent. Each step can be either +/// **inline** (executed directly by the current Run) or **delegated** +/// (spawning a sub-Task via the `task` field). See module documentation +/// for context tracking and recursive decomposition details. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PlanStep { - pub intent: String, - pub inputs: Option, - pub outputs: Option, - pub checks: Option, - pub owner_role: Option, - pub status: PlanStatus, + /// Human-readable description of what this step should accomplish. + /// + /// Set once at creation. The `alias = "intent"` supports legacy + /// serialized data where this field was named `intent`. + #[serde(alias = "intent")] + description: String, + /// Expected inputs for this step as a JSON value. + /// + /// Schema is step-dependent. For example, a "refactor" step might + /// list `{"files": ["src/auth.rs"]}`. `None` when the step has no + /// explicit inputs (e.g. a discovery step). + #[serde(default, skip_serializing_if = "Option::is_none")] + inputs: Option, + /// Expected outputs for this step as a JSON value. + /// + /// Populated after execution completes. For example, + /// `{"files_modified": ["src/auth.rs", "src/lib.rs"]}`. `None` + /// while the step is `Pending` or `Progressing`, or when the step + /// produces no structured output. + #[serde(default, skip_serializing_if = "Option::is_none")] + outputs: Option, + /// Validation criteria for this step as a JSON value. + /// + /// Defines what must pass for the step to be considered successful. + /// For example, `{"tests": "cargo test", "lint": "cargo clippy"}`. + /// `None` when no explicit checks are defined. + #[serde(default, skip_serializing_if = "Option::is_none")] + checks: Option, + /// Indices into the pipeline's frame list that this step **consumed** + /// as input context. + /// + /// Set when the step begins execution. Values are stable + /// [`ContextFrame::frame_id`](super::pipeline::ContextFrame::frame_id)s + /// (not Vec indices), so they survive pipeline eviction. Look up + /// frames via + /// [`ContextPipeline::frame_by_id`](super::pipeline::ContextPipeline::frame_by_id). + /// Empty when no prior context was consumed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + iframes: Vec, + /// Stable frame IDs in the pipeline that this step **produced** + /// as output context. + /// + /// Set after the step completes. The step pushes new frames (e.g. + /// `StepSummary`, `CodeChange`) to the pipeline and records the + /// returned `frame_id`s here. Empty when the step produced no + /// context frames. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + oframes: Vec, + /// Optional sub-[`Task`](super::task::Task) spawned for this step. + /// + /// When set, the step delegates execution to an independent Task + /// with its own Run / Intent / Plan lifecycle (recursive + /// decomposition). The sub-Task's `parent` field points back to + /// the owning Task. When `None`, the step is executed inline by + /// the current Run. + #[serde(default, skip_serializing_if = "Option::is_none")] + task: Option, + /// Append-only chronological history of status transitions. + /// + /// Initialized with a single `Pending` entry at creation. The + /// current status is always `statuses.last().status`. + /// + /// `#[serde(default)]` ensures backward compatibility with the + /// legacy schema that used a single `status: PlanStatus` field. + /// When deserializing old data that lacks `statuses`, the default + /// produces a single `Pending` entry. + #[serde(default = "default_step_statuses")] + statuses: Vec, } -/// Plan object for step decomposition. -/// New versions are created via `new_next` with monotonic versioning. +impl PlanStep { + pub fn new(description: impl Into) -> Self { + Self { + description: description.into(), + inputs: None, + outputs: None, + checks: None, + iframes: Vec::new(), + oframes: Vec::new(), + task: None, + statuses: vec![StepStatusEntry::new(StepStatus::Pending, None)], + } + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn inputs(&self) -> Option<&serde_json::Value> { + self.inputs.as_ref() + } + + pub fn outputs(&self) -> Option<&serde_json::Value> { + self.outputs.as_ref() + } + + pub fn checks(&self) -> Option<&serde_json::Value> { + self.checks.as_ref() + } + + /// Returns the current step status (last entry in the history). + /// + /// Returns `None` only if `statuses` is empty, which should not + /// happen for objects created via [`PlanStep::new`] (seeds with + /// `Pending`), but may occur for malformed deserialized data. + pub fn status(&self) -> Option<&StepStatus> { + self.statuses.last().map(|e| &e.status) + } + + /// Returns the full chronological status history. + pub fn statuses(&self) -> &[StepStatusEntry] { + &self.statuses + } + + /// Transitions the step to a new status, appending to the history. + pub fn set_status(&mut self, status: StepStatus) { + self.statuses.push(StepStatusEntry::new(status, None)); + } + + /// Transitions the step to a new status with a reason. + pub fn set_status_with_reason(&mut self, status: StepStatus, reason: impl Into) { + self.statuses + .push(StepStatusEntry::new(status, Some(reason.into()))); + } + + pub fn set_inputs(&mut self, inputs: Option) { + self.inputs = inputs; + } + + pub fn set_outputs(&mut self, outputs: Option) { + self.outputs = outputs; + } + + pub fn set_checks(&mut self, checks: Option) { + self.checks = checks; + } + + /// Returns the pipeline frame IDs this step consumed as input context. + pub fn iframes(&self) -> &[u64] { + &self.iframes + } + + /// Returns the pipeline frame IDs this step produced as output context. + pub fn oframes(&self) -> &[u64] { + &self.oframes + } + + /// Records the pipeline frame IDs this step consumed as input. + pub fn set_iframes(&mut self, ids: Vec) { + self.iframes = ids; + } + + /// Records the pipeline frame IDs this step produced as output. + pub fn set_oframes(&mut self, ids: Vec) { + self.oframes = ids; + } + + /// Returns the sub-Task ID if this step has been elevated to an + /// independent Task. + pub fn task(&self) -> Option { + self.task + } + + /// Elevates this step to an independent sub-Task, or clears the + /// association by passing `None`. + pub fn set_task(&mut self, task: Option) { + self.task = task; + } +} + +/// A sequence of steps derived from an Intent's analyzed content. +/// +/// A Plan is a pure planning artifact — it defines *what* to do, not +/// *how* to execute. It is step ③ in the end-to-end flow. A +/// [`Run`](super::run::Run) then references the Plan via `plan` to +/// execute it. See module documentation for revision chain, context +/// range, and recursive decomposition details. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Plan { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, - run_id: Uuid, - /// Plan version starts at 1 and must increase by 1 for each update. - plan_version: u32, - #[serde(default)] - previous_plan_id: Option, + /// Link to the predecessor Plan in the revision chain. + /// + /// Forms a singly-linked list from newest to oldest: each revised + /// Plan points to the Plan it supersedes. `None` for the initial + /// (first) Plan. The [`Intent`](super::intent::Intent) always + /// points to the latest revision via `Intent.plan`. + /// + /// Use [`new_revision`](Plan::new_revision) to create a successor + /// that automatically sets this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + previous: Option, + /// The [`ContextPipeline`](super::pipeline::ContextPipeline) that + /// served as the context basis for this Plan. + /// + /// Set when the Plan is created from an Intent's analyzed content. + /// The pipeline contains the [`ContextFrame`](super::pipeline::ContextFrame)s + /// that informed this Plan's decomposition. `None` when no pipeline + /// was used (e.g. a manually created Plan). + #[serde(default, skip_serializing_if = "Option::is_none")] + pipeline: Option, + /// Frame visibility window `(start, end)`. + /// + /// A half-open range `[start..end)` into the pipeline's frame list + /// that was visible when this Plan was created. Enables + /// retrospective analysis: given the context the agent saw, was the + /// decomposition reasonable? `None` when `pipeline` is not set or + /// when the entire pipeline was visible. + #[serde(default, skip_serializing_if = "Option::is_none")] + fwindow: Option<(u32, u32)>, + /// Ordered sequence of steps to execute. + /// + /// Steps are executed in order (index 0 first). Each step can be + /// inline (executed by the current Run) or delegated (spawning a + /// sub-Task via `PlanStep.task`). Empty when the Plan has just been + /// created and steps haven't been added yet. #[serde(default)] steps: Vec, } impl Plan { - /// Create a new plan object (version 1) - pub fn new(repo_id: Uuid, created_by: ActorRef, run_id: Uuid) -> Result { + /// Create a new initial plan (no predecessor). + pub fn new(created_by: ActorRef) -> Result { Ok(Self { - header: Header::new(ObjectType::Plan, repo_id, created_by)?, - run_id, - plan_version: 1, - previous_plan_id: None, + header: Header::new(ObjectType::Plan, created_by)?, + previous: None, + pipeline: None, + fwindow: None, steps: Vec::new(), }) } - /// Create the next version of a plan. - /// - /// Links the new plan back to the previous one via `previous_plan_id`. - /// - /// # Arguments - /// * `repo_id` - Repository the plan belongs to. - /// * `created_by` - Actor creating the new version. - /// * `run_id` - Run this plan is associated with. - pub fn new_next( - &self, - repo_id: Uuid, - created_by: ActorRef, - run_id: Uuid, - ) -> Result { - let next_version = self - .plan_version - .checked_add(1) - .ok_or_else(|| "plan_version overflow".to_string())?; + /// Create a revised plan that links back to this one. + pub fn new_revision(&self, created_by: ActorRef) -> Result { Ok(Self { - header: Header::new(ObjectType::Plan, repo_id, created_by)?, - run_id, - plan_version: next_version, - previous_plan_id: Some(self.header.object_id()), + header: Header::new(ObjectType::Plan, created_by)?, + previous: Some(self.header.object_id()), + pipeline: None, + fwindow: None, steps: Vec::new(), }) } @@ -129,16 +492,8 @@ impl Plan { &self.header } - pub fn run_id(&self) -> Uuid { - self.run_id - } - - pub fn plan_version(&self) -> u32 { - self.plan_version - } - - pub fn previous_plan_id(&self) -> Option { - self.previous_plan_id + pub fn previous(&self) -> Option { + self.previous } pub fn steps(&self) -> &[PlanStep] { @@ -149,8 +504,30 @@ impl Plan { self.steps.push(step); } - pub fn set_previous_plan_id(&mut self, previous_plan_id: Option) { - self.previous_plan_id = previous_plan_id; + pub fn set_previous(&mut self, previous: Option) { + self.previous = previous; + } + + /// Returns the pipeline that served as context basis for this plan. + pub fn pipeline(&self) -> Option { + self.pipeline + } + + /// Sets the pipeline that serves as the context basis for this plan. + pub fn set_pipeline(&mut self, pipeline: Option) { + self.pipeline = pipeline; + } + + /// Returns the frame window `(start, end)` — the half-open range + /// `[start..end)` of pipeline frames visible when this plan was created. + pub fn fwindow(&self) -> Option<(u32, u32)> { + self.fwindow + } + + /// Sets the frame window `(start, end)` — the half-open range + /// `[start..end)` of pipeline frames visible when this plan was created. + pub fn set_fwindow(&mut self, fwindow: Option<(u32, u32)>) { + self.fwindow = fwindow; } } @@ -189,40 +566,197 @@ impl ObjectTrait for Plan { #[cfg(test)] mod tests { + use serde_json::json; + use super::*; #[test] - fn test_plan_version_ordering() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); + fn test_plan_revision_chain() { let actor = ActorRef::human("jackie").expect("actor"); - let run_id = Uuid::from_u128(0x1); - let plan_v1 = Plan::new(repo_id, actor.clone(), run_id).expect("plan"); - let plan_v2 = plan_v1 - .new_next(repo_id, actor.clone(), run_id) - .expect("plan"); - let plan_v3 = plan_v2 - .new_next(repo_id, actor.clone(), run_id) - .expect("plan"); + let plan_v1 = Plan::new(actor.clone()).expect("plan"); + let plan_v2 = plan_v1.new_revision(actor.clone()).expect("plan"); + let plan_v3 = plan_v2.new_revision(actor.clone()).expect("plan"); - let mut plans = [plan_v2.clone(), plan_v1.clone(), plan_v3.clone()]; - plans.sort_by_key(|plan| plan.plan_version()); + // Initial plan has no predecessor + assert!(plan_v1.previous().is_none()); - assert_eq!(plans[0].plan_version(), 1); - assert_eq!(plans[1].plan_version(), 2); - assert_eq!(plans[2].plan_version(), 3); + // Revision chain links back correctly + assert_eq!(plan_v2.previous(), Some(plan_v1.header().object_id())); + assert_eq!(plan_v3.previous(), Some(plan_v2.header().object_id())); - assert!(plan_v3.plan_version() > plan_v2.plan_version()); - assert!(plan_v2.plan_version() > plan_v1.plan_version()); + // Chronological ordering via header timestamps + assert!(plan_v2.header().created_at() >= plan_v1.header().created_at()); + assert!(plan_v3.header().created_at() >= plan_v2.header().created_at()); + } + + #[test] + fn test_plan_pipeline_and_fwindow() { + let actor = ActorRef::human("jackie").expect("actor"); + let mut plan = Plan::new(actor).expect("plan"); + + assert!(plan.pipeline().is_none()); + assert!(plan.fwindow().is_none()); + + let pipeline_id = Uuid::from_u128(0x42); + plan.set_pipeline(Some(pipeline_id)); + plan.set_fwindow(Some((0, 3))); + + assert_eq!(plan.pipeline(), Some(pipeline_id)); + assert_eq!(plan.fwindow(), Some((0, 3))); + } + + #[test] + fn test_plan_step_statuses() { + let mut step = PlanStep::new("run tests"); + + // Initial state: one Pending entry + assert_eq!(step.statuses().len(), 1); + assert_eq!(step.status(), Some(&StepStatus::Pending)); + + // Transition to Progressing + step.set_status(StepStatus::Progressing); + assert_eq!(step.status(), Some(&StepStatus::Progressing)); + assert_eq!(step.statuses().len(), 2); + + // Transition to Completed with reason + step.set_status_with_reason(StepStatus::Completed, "all checks passed"); + assert_eq!(step.status(), Some(&StepStatus::Completed)); + assert_eq!(step.statuses().len(), 3); + + // Verify full history + let history = step.statuses(); + assert_eq!(history[0].status(), &StepStatus::Pending); + assert!(history[0].reason().is_none()); + assert_eq!(history[1].status(), &StepStatus::Progressing); + assert!(history[1].reason().is_none()); + assert_eq!(history[2].status(), &StepStatus::Completed); + assert_eq!(history[2].reason(), Some("all checks passed")); + + // Timestamps are ordered + assert!(history[1].changed_at() >= history[0].changed_at()); + assert!(history[2].changed_at() >= history[1].changed_at()); + } + + #[test] + fn test_plan_step_deserializes_legacy_intent_field() { + let step: PlanStep = serde_json::from_value(json!({ + "intent": "run tests", + "statuses": [{"status": "pending", "changed_at": "2026-01-01T00:00:00Z"}] + })) + .expect("deserialize legacy step"); + + assert_eq!(step.description(), "run tests"); + } + + #[test] + fn test_plan_step_serializes_description_field() { + let step = PlanStep::new("run tests"); + let value = serde_json::to_value(&step).expect("serialize step"); - assert!(plan_v1.previous_plan_id().is_none()); - assert_eq!( - plan_v2.previous_plan_id(), - Some(plan_v1.header().object_id()) - ); assert_eq!( - plan_v3.previous_plan_id(), - Some(plan_v2.header().object_id()) + value.get("description").and_then(|v| v.as_str()), + Some("run tests") ); + assert!(value.get("intent").is_none()); + } + + #[test] + fn test_plan_step_context_frames() { + let mut step = PlanStep::new("refactor auth module"); + + // Initially empty + assert!(step.iframes().is_empty()); + assert!(step.oframes().is_empty()); + + // Step consumed frames 0 and 1 as input context + step.set_iframes(vec![0, 1]); + // Step produced frame 2 as output + step.set_oframes(vec![2]); + + assert_eq!(step.iframes(), &[0, 1]); + assert_eq!(step.oframes(), &[2]); + } + + #[test] + fn test_plan_step_context_frames_serde_roundtrip() { + let mut step = PlanStep::new("deploy"); + step.set_iframes(vec![0, 3]); + step.set_oframes(vec![4, 5]); + + let value = serde_json::to_value(&step).expect("serialize"); + let restored: PlanStep = serde_json::from_value(value).expect("deserialize"); + + assert_eq!(restored.iframes(), &[0, 3]); + assert_eq!(restored.oframes(), &[4, 5]); + } + + #[test] + fn test_plan_step_empty_frames_omitted_in_json() { + let step = PlanStep::new("noop"); + let value = serde_json::to_value(&step).expect("serialize"); + + // Empty vecs should be omitted (skip_serializing_if = "Vec::is_empty") + assert!(value.get("iframes").is_none()); + assert!(value.get("oframes").is_none()); + } + + #[test] + fn test_plan_fwindow_serde_roundtrip() { + let actor = ActorRef::human("jackie").expect("actor"); + let mut plan = Plan::new(actor).expect("plan"); + plan.set_pipeline(Some(Uuid::from_u128(0x99))); + plan.set_fwindow(Some((2, 7))); + + let mut step = PlanStep::new("step 0"); + step.set_iframes(vec![2, 3]); + step.set_oframes(vec![7]); + plan.add_step(step); + + let data = plan.to_data().expect("serialize"); + let restored = Plan::from_bytes(&data, ObjectHash::default()).expect("deserialize"); + + assert_eq!(restored.fwindow(), Some((2, 7))); + assert_eq!(restored.steps()[0].iframes(), &[2, 3]); + assert_eq!(restored.steps()[0].oframes(), &[7]); + } + + #[test] + fn test_plan_step_subtask() { + let mut step = PlanStep::new("design OAuth flow"); + + // Initially no sub-task + assert!(step.task().is_none()); + + // Elevate to independent sub-Task + let sub_task_id = Uuid::from_u128(0xAB); + step.set_task(Some(sub_task_id)); + assert_eq!(step.task(), Some(sub_task_id)); + + // Clear association + step.set_task(None); + assert!(step.task().is_none()); + } + + #[test] + fn test_plan_step_subtask_serde_roundtrip() { + let mut step = PlanStep::new("implement auth module"); + let sub_task_id = Uuid::from_u128(0xCD); + step.set_task(Some(sub_task_id)); + + let value = serde_json::to_value(&step).expect("serialize"); + assert!(value.get("task").is_some()); + + let restored: PlanStep = serde_json::from_value(value).expect("deserialize"); + assert_eq!(restored.task(), Some(sub_task_id)); + } + + #[test] + fn test_plan_step_no_subtask_omitted_in_json() { + let step = PlanStep::new("inline step"); + let value = serde_json::to_value(&step).expect("serialize"); + + // None task should be omitted (skip_serializing_if) + assert!(value.get("task").is_none()); } } diff --git a/src/internal/object/provenance.rs b/src/internal/object/provenance.rs index 1af9fc4f..8808c0e3 100644 --- a/src/internal/object/provenance.rs +++ b/src/internal/object/provenance.rs @@ -1,14 +1,37 @@ //! AI Provenance Definition //! -//! `Provenance` captures metadata about *how* a run was executed, specifically focusing on -//! the model (LLM) and provider configuration. +//! A `Provenance` records **how** a [`Run`](super::run::Run) was executed: +//! which LLM provider, model, and parameters were used, and how many +//! tokens were consumed. It is the "lab notebook" for AI execution — +//! capturing the exact configuration so results can be reproduced, +//! compared, and accounted for. //! -//! # Usage +//! # Position in Lifecycle //! -//! This is critical for: -//! - **Reproducibility**: Knowing which model version produced a result. -//! - **Cost Accounting**: Tracking token usage per run. -//! - **Optimization**: Comparing performance across different models or parameters. +//! ```text +//! Run ──(1:1)──▶ Provenance +//! │ +//! ├── patchsets ──▶ [PatchSet₀, ...] +//! ├── evidence ──▶ [Evidence₀, ...] +//! └── decision ──▶ Decision +//! ``` +//! +//! A Provenance is created **once per Run**, typically at run start +//! when the orchestrator selects the model and provider. Token usage +//! (`token_usage`) is populated after the Run completes. The +//! Provenance is a sibling of PatchSet, Evidence, and Decision — +//! all attached to the same Run but serving different purposes. +//! +//! # Purpose +//! +//! - **Reproducibility**: Given the same model, parameters, and +//! [`ContextSnapshot`](super::context::ContextSnapshot), the agent +//! should produce equivalent results. +//! - **Cost Accounting**: `token_usage.cost_usd` enables per-Run and +//! per-Task cost tracking and budgeting. +//! - **Optimization**: Comparing Provenance across Runs of the same +//! Task reveals which model/parameter combinations yield better +//! results or lower cost. use std::fmt; @@ -25,11 +48,22 @@ use crate::{ }; /// Normalized token usage across providers. +/// +/// All fields use a provider-neutral representation so that usage +/// from different LLM providers (OpenAI, Anthropic, etc.) can be +/// compared directly. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TokenUsage { + /// Number of tokens in the prompt / input. pub input_tokens: u64, + /// Number of tokens in the completion / output. pub output_tokens: u64, + /// `input_tokens + output_tokens`. Stored explicitly for quick + /// aggregation; [`is_consistent`](TokenUsage::is_consistent) + /// verifies the invariant. pub total_tokens: u64, + /// Estimated cost in USD for this usage, if the provider reports + /// pricing. `None` when pricing data is unavailable. pub cost_usd: Option, } @@ -46,35 +80,72 @@ impl TokenUsage { } } -/// Provenance object for model/provider metadata. -/// Captures model/provider settings and usage. +/// LLM provider/model configuration and usage for a single Run. +/// +/// Created once per Run. See module documentation for lifecycle +/// position and purpose. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Provenance { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, + /// The [`Run`](super::run::Run) this Provenance describes. + /// + /// Every Provenance belongs to exactly one Run. The Run does not + /// store a back-reference; lookup is done by scanning or indexing. run_id: Uuid, + /// LLM provider identifier (e.g. "openai", "anthropic", "local"). + /// + /// Used together with `model` to fully identify the AI backend. + /// The value is a free-form string; no enum is imposed because + /// new providers appear frequently. provider: String, + /// Model identifier as returned by the provider (e.g. + /// "gpt-4", "claude-opus-4-20250514", "llama-3-70b"). + /// + /// Should match the provider's official model ID so that results + /// can be correlated with the provider's documentation and pricing. model: String, - #[serde(default)] + /// Provider-specific raw parameters payload. + /// + /// A catch-all JSON object for parameters that don't have + /// dedicated fields (e.g. `top_p`, `frequency_penalty`, custom + /// system prompts). `None` when no extra parameters were set. + /// `temperature` and `max_tokens` are extracted into dedicated + /// fields for convenience but may also appear here. + #[serde(default, skip_serializing_if = "Option::is_none")] parameters: Option, - #[serde(default)] + /// Sampling temperature used for generation. + /// + /// `0.0` = deterministic, higher = more creative. `None` if the + /// provider default was used. The getter falls back to + /// `parameters.temperature` when this field is not set. + #[serde(default, skip_serializing_if = "Option::is_none")] temperature: Option, - #[serde(default)] + /// Maximum number of tokens the model was allowed to generate. + /// + /// `None` if the provider default was used. The getter falls back + /// to `parameters.max_tokens` when this field is not set. + #[serde(default, skip_serializing_if = "Option::is_none")] max_tokens: Option, - #[serde(default)] + /// Token consumption and cost for this Run. + /// + /// Populated after the Run completes. `None` while the Run is + /// still in progress or if the provider does not report usage. + /// See [`TokenUsage`] for field details. + #[serde(default, skip_serializing_if = "Option::is_none")] token_usage: Option, } impl Provenance { pub fn new( - repo_id: Uuid, created_by: ActorRef, run_id: Uuid, provider: impl Into, model: impl Into, ) -> Result { Ok(Self { - header: Header::new(ObjectType::Provenance, repo_id, created_by)?, + header: Header::new(ObjectType::Provenance, created_by)?, run_id, provider: provider.into(), model: model.into(), @@ -186,12 +257,10 @@ mod tests { #[test] fn test_provenance_fields() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::agent("test-agent").expect("actor"); let run_id = Uuid::from_u128(0x1); - let mut provenance = - Provenance::new(repo_id, actor, run_id, "openai", "gpt-4").expect("provenance"); + let mut provenance = Provenance::new(actor, run_id, "openai", "gpt-4").expect("provenance"); provenance.set_parameters(Some( serde_json::json!({"temperature": 0.2, "max_tokens": 128}), )); diff --git a/src/internal/object/run.rs b/src/internal/object/run.rs index 071ff977..f33d7c69 100644 --- a/src/internal/object/run.rs +++ b/src/internal/object/run.rs @@ -1,20 +1,77 @@ //! AI Run Definition //! -//! A `Run` represents a single execution instance of an AI agent attempting to perform a `Task`. -//! It captures the execution context (environment, agent role) and tracks the progress. +//! A [`Run`] is a single execution attempt of a +//! [`Task`](super::task::Task). It captures the execution context +//! (baseline commit, environment, Plan version) and accumulates +//! artifacts ([`PatchSet`](super::patchset::PatchSet)s, +//! [`Evidence`](super::evidence::Evidence), +//! [`ToolInvocation`](super::tool::ToolInvocation)s) during execution. +//! The Run is step ⑤ in the end-to-end flow described in +//! [`mod.rs`](super). //! -//! # Relationship to Task +//! # Position in Lifecycle //! -//! A `Task` can have multiple `Run`s. This happens when: -//! - An agent fails and retries. -//! - A user requests a different approach. -//! - Multiple agents work on the same task in parallel (future). +//! ```text +//! ④ Task ──runs──▶ [Run₀, Run₁, ...] +//! │ +//! ▼ +//! ⑤ Run (Created → Patching → Validating → Completed/Failed) +//! │ +//! ├──task──▶ Task (mandatory, 1:1) +//! ├──plan──▶ Plan (snapshot reference) +//! ├──snapshot──▶ ContextSnapshot (optional) +//! │ +//! │ ┌─── agent execution loop ───┐ +//! │ │ │ +//! │ │ ⑥ ToolInvocation (1:N) │ +//! │ │ │ │ +//! │ │ ▼ │ +//! │ │ ⑦ PatchSet (Proposed) │ +//! │ │ │ │ +//! │ │ ▼ │ +//! │ │ ⑧ Evidence (1:N) │ +//! │ │ │ │ +//! │ │ ├─ pass ─────────────┘ +//! │ │ └─ fail → new PatchSet +//! │ └────────────────────────────┘ +//! │ +//! ▼ +//! ⑨ Decision (terminal verdict) +//! ``` //! -//! # Key Fields +//! # Status Transitions //! -//! - `task_id`: Links back to the parent Task. -//! - `base_commit_sha`: The Git commit hash where this run started. -//! - `context_snapshot_id`: Links to the captured context (files, docs) used. +//! ```text +//! Created ──▶ Patching ──▶ Validating ──▶ Completed +//! │ │ +//! └──────────────┴──▶ Failed +//! ``` +//! +//! # Relationships +//! +//! | Field | Target | Cardinality | Notes | +//! |-------|--------|-------------|-------| +//! | `task` | Task | 1 | Mandatory owning Task | +//! | `plan` | Plan | 0..1 | Snapshot reference (frozen at Run start) | +//! | `snapshot` | ContextSnapshot | 0..1 | Static context at Run start | +//! | `patchsets` | PatchSet | 0..N | Candidate diffs, chronological | +//! +//! Reverse references (by `run_id`): +//! - `Provenance.run_id` → this Run (1:1, LLM config) +//! - `ToolInvocation.run_id` → this Run (1:N, action log) +//! - `Evidence.run_id` → this Run (1:N, validation results) +//! - `Decision.run_id` → this Run (1:1, terminal verdict) +//! +//! # Purpose +//! +//! - **Execution Context**: Records the baseline `commit`, host +//! `environment`, and Plan version so that results can be +//! reproduced. +//! - **Artifact Collection**: Accumulates PatchSets (candidate diffs) +//! during the agent execution loop. +//! - **Isolation**: Each Run is independent — a retry creates a new +//! Run with potentially different parameters, without mutating the +//! previous Run's state. use std::{collections::HashMap, fmt}; @@ -31,19 +88,30 @@ use crate::{ }, }; -/// Run lifecycle status. +/// Lifecycle status of a [`Run`]. +/// +/// See module docs for the status transition diagram. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RunStatus { - /// Run created, agent not yet started. + /// Run has been created but the agent has not started execution. + /// Environment and baseline commit are captured at this point. Created, - /// Agent is generating patches. + /// Agent is actively generating code changes. One or more + /// [`ToolInvocation`](super::tool::ToolInvocation)s are being + /// produced. Patching, - /// Agent is running verification tools. + /// Agent has produced a candidate + /// [`PatchSet`](super::patchset::PatchSet) and is running + /// validation tools (tests, lint, build). One or more + /// [`Evidence`](super::evidence::Evidence) objects are being + /// produced. Validating, - /// Agent has finished successfully. + /// Agent has finished successfully. A + /// [`Decision`](super::decision::Decision) has been created. Completed, - /// Agent encountered an unrecoverable error. + /// Agent encountered an unrecoverable error. `Run.error` should + /// contain the error message. Failed, } @@ -65,13 +133,21 @@ impl fmt::Display for RunStatus { } } -/// Environment snapshot of the run host. -/// Captured at run creation time. +/// Host environment snapshot captured at Run creation time. +/// +/// Records the OS, CPU architecture, and working directory so that +/// results can be correlated with the execution environment. The +/// `extra` map allows capturing additional environment details +/// (e.g. tool versions, environment variables) without schema changes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Environment { - pub os: String, // e.g. "macos", "linux" - pub arch: String, // e.g. "aarch64", "x86_64" - pub cwd: String, // Current working directory + /// Operating system identifier (e.g. "macos", "linux", "windows"). + pub os: String, + /// CPU architecture (e.g. "aarch64", "x86_64"). + pub arch: String, + /// Current working directory at Run creation time. + pub cwd: String, + /// Additional environment details (tool versions, etc.). #[serde(flatten)] pub extra: HashMap, } @@ -93,28 +169,91 @@ impl Environment { } } -/// Agent instance participating in a run. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentInstance { - pub role: String, - pub provider_route: Option, -} - -/// Run object for a single orchestration execution. -/// Links a task to execution state and environment. +/// A single execution attempt of a [`Task`](super::task::Task). +/// +/// A Run captures the execution context and accumulates artifacts +/// during the agent's work. It is step ⑤ in the end-to-end flow. +/// See module documentation for lifecycle, relationships, and +/// status transitions. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Run { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, - task_id: Uuid, - orchestrator_version: String, - base_commit_sha: IntegrityHash, + /// The [`Task`](super::task::Task) this Run belongs to. + /// + /// Mandatory — every Run is an execution attempt of exactly one + /// Task. `Task.runs` holds the reverse reference. This field is + /// set at creation and never changes. + task: Uuid, + /// The [`Plan`](super::plan::Plan) this Run is executing. + /// + /// This is a **snapshot reference**: it records the specific Plan + /// version that was active when this Run started. After + /// replanning, existing Runs keep their original `plan` unchanged + /// — only new Runs reference the revised Plan. + /// `Intent.plan` always points to the latest revision, but a Run + /// may be executing an older version. `None` when no Plan was + /// associated (e.g. ad-hoc execution without formal planning). + #[serde(default, skip_serializing_if = "Option::is_none")] + plan: Option, + /// Git commit hash of the working tree when this Run started. + /// + /// Serves as the baseline for all code changes: the agent reads + /// files at this commit, and the resulting + /// [`PatchSet`](super::patchset::PatchSet) diffs are relative to + /// it. If the Run fails and a new Run is created, the new Run + /// may start from a different commit (e.g. after upstream changes + /// are pulled). + commit: IntegrityHash, + /// Current lifecycle status. + /// + /// Transitions follow the sequence: + /// `Created → Patching → Validating → Completed` (happy path), + /// or `→ Failed` from any active state. The orchestrator advances + /// the status as the agent progresses through execution phases. status: RunStatus, - context_snapshot_id: Option, - #[serde(default)] - agent_instances: Vec, + /// Optional [`ContextSnapshot`](super::context::ContextSnapshot) + /// captured at Run creation time. + /// + /// Records the file tree, documentation fragments, and other + /// static context the agent observed when the Run began. Used + /// for reproducibility: given the same snapshot and Plan, the + /// agent should produce equivalent results. `None` when no + /// snapshot was captured. + #[serde(default, skip_serializing_if = "Option::is_none")] + snapshot: Option, + /// Chronological list of [`PatchSet`](super::patchset::PatchSet) + /// IDs generated during this Run. + /// + /// Append-only — each new PatchSet is pushed to the end. The + /// last entry is the most recent candidate. A Run may produce + /// multiple PatchSets when the agent iterates on validation + /// failures (step ⑦ → ⑧ retry loop). Empty when no PatchSet + /// has been generated yet. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + patchsets: Vec, + /// Execution metrics (token usage, timing, etc.). + /// + /// Free-form JSON for metrics not captured by + /// [`Provenance`](super::provenance::Provenance). For example, + /// wall-clock duration, number of tool calls, or retry count. + /// `None` when no metrics are available. + #[serde(default, skip_serializing_if = "Option::is_none")] metrics: Option, + /// Error message if the Run failed. + /// + /// Set when `status` transitions to `Failed`. Contains a + /// human-readable description of what went wrong. `None` while + /// the Run is in progress or completed successfully. + #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, + /// Host [`Environment`] snapshot captured at Run creation time. + /// + /// Automatically populated by [`Run::new`] via + /// [`Environment::capture`]. Records OS, architecture, and + /// working directory for reproducibility. + #[serde(default, skip_serializing_if = "Option::is_none")] environment: Option, } @@ -122,25 +261,19 @@ impl Run { /// Create a new Run. /// /// # Arguments - /// * `repo_id` - Repository UUID /// * `created_by` - Actor (usually the Orchestrator) - /// * `task_id` - The Task this run belongs to - /// * `base_commit_sha` - The Git commit hash of the checkout - pub fn new( - repo_id: Uuid, - created_by: ActorRef, - task_id: Uuid, - base_commit_sha: impl AsRef, - ) -> Result { - let base_commit_sha = base_commit_sha.as_ref().parse()?; + /// * `task` - The Task this run belongs to + /// * `commit` - The Git commit hash of the checkout + pub fn new(created_by: ActorRef, task: Uuid, commit: impl AsRef) -> Result { + let commit = commit.as_ref().parse()?; Ok(Self { - header: Header::new(ObjectType::Run, repo_id, created_by)?, - task_id, - orchestrator_version: "libra-builtin".to_string(), - base_commit_sha, + header: Header::new(ObjectType::Run, created_by)?, + task, + plan: None, + commit, status: RunStatus::Created, - context_snapshot_id: None, - agent_instances: Vec::new(), + snapshot: None, + patchsets: Vec::new(), metrics: None, error: None, environment: Some(Environment::capture()), @@ -151,28 +284,35 @@ impl Run { &self.header } - pub fn task_id(&self) -> Uuid { - self.task_id + pub fn task(&self) -> Uuid { + self.task + } + + /// Returns the Plan this Run is executing, if set. + pub fn plan(&self) -> Option { + self.plan } - pub fn orchestrator_version(&self) -> &str { - &self.orchestrator_version + /// Sets the Plan this Run will execute. + pub fn set_plan(&mut self, plan: Option) { + self.plan = plan; } - pub fn base_commit_sha(&self) -> &IntegrityHash { - &self.base_commit_sha + pub fn commit(&self) -> &IntegrityHash { + &self.commit } pub fn status(&self) -> &RunStatus { &self.status } - pub fn context_snapshot_id(&self) -> Option { - self.context_snapshot_id + pub fn snapshot(&self) -> Option { + self.snapshot } - pub fn agent_instances(&self) -> &[AgentInstance] { - &self.agent_instances + /// Returns the chronological list of PatchSet IDs generated during this Run. + pub fn patchsets(&self) -> &[Uuid] { + &self.patchsets } pub fn metrics(&self) -> Option<&serde_json::Value> { @@ -191,12 +331,13 @@ impl Run { self.status = status; } - pub fn set_context_snapshot_id(&mut self, context_snapshot_id: Option) { - self.context_snapshot_id = context_snapshot_id; + pub fn set_snapshot(&mut self, snapshot: Option) { + self.snapshot = snapshot; } - pub fn add_agent_instance(&mut self, instance: AgentInstance) { - self.agent_instances.push(instance); + /// Appends a PatchSet ID to this Run's generation history. + pub fn add_patchset(&mut self, patchset_id: Uuid) { + self.patchsets.push(patchset_id); } pub fn set_metrics(&mut self, metrics: Option) { @@ -251,12 +392,11 @@ mod tests { #[test] fn test_new_objects_creation() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::agent("test-agent").expect("actor"); let base_hash = test_hash_hex(); // Run with environment (auto captured) - let run = Run::new(repo_id, actor.clone(), Uuid::from_u128(0x1), &base_hash).expect("run"); + let run = Run::new(actor.clone(), Uuid::from_u128(0x1), &base_hash).expect("run"); let env = run.environment().unwrap(); // Check if it captured real values (assuming we are running on some OS) diff --git a/src/internal/object/task.rs b/src/internal/object/task.rs index 6f48ea4a..7aece93a 100644 --- a/src/internal/object/task.rs +++ b/src/internal/object/task.rs @@ -1,36 +1,76 @@ //! AI Task Definition //! -//! A `Task` represents a unit of work to be performed by an AI agent. -//! It serves as the root of the AI workflow, defining intent, constraints, and success criteria. +//! A [`Task`] is a unit of work to be performed by an AI agent. It is +//! step ④ in the end-to-end flow described in [`mod.rs`](super) — the +//! stable identity for a piece of work, independent of how many times +//! it is attempted (Runs) or how the strategy evolves (Plan revisions). //! -//! # Lifecycle +//! # Position in Lifecycle //! -//! 1. **Draft**: Initial state. Task is being defined. -//! 2. **Running**: An agent (via a `Run` object) has started working on it. -//! 3. **Done**: Work is completed and verified. -//! 4. **Failed**: Work could not be completed. -//! 5. **Cancelled**: User aborted the task. +//! ```text +//! ③ Plan ──steps──▶ [PlanStep₀, PlanStep₁, ...] +//! │ +//! ├─ inline (no task) +//! └─ task ──▶ ④ Task +//! │ +//! ├──▶ Run₀ ──plan──▶ Plan_v1 +//! ├──▶ Run₁ ──plan──▶ Plan_v2 +//! │ +//! ▼ +//! ⑤ Run (execution) +//! ``` +//! +//! # Status Transitions +//! +//! ```text +//! Draft ──▶ Running ──▶ Done +//! │ │ +//! ├──────────┴──▶ Failed +//! └──────────────▶ Cancelled +//! ``` //! //! # Relationships //! -//! - **Parent**: None (Root object). -//! - **Children**: `Run` (1-to-many). A task can have multiple runs (retries). -//! - **Dependencies**: Can depend on other Tasks via `dependencies`. +//! | Field | Target | Cardinality | Notes | +//! |-------|--------|-------------|-------| +//! | `parent` | Task | 0..1 | Back-reference to parent Task for sub-Tasks | +//! | `intent` | Intent | 0..1 | Originating user request | +//! | `runs` | Run | 0..N | Chronological execution history | +//! | `dependencies` | Task | 0..N | Must complete before this Task starts | //! -//! # Example +//! Reverse references: +//! - `PlanStep.task` → this Task (forward link from Plan) +//! - `Run.task` → this Task (each Run knows its owner) //! -//! ```rust -//! use git_internal::internal::object::task::{Task, GoalType}; -//! use git_internal::internal::object::types::ActorRef; -//! use uuid::Uuid; +//! # Replanning //! -//! let repo_id = Uuid::new_v4(); -//! let actor = ActorRef::human("user").unwrap(); -//! let mut task = Task::new(repo_id, actor, "Refactor Login", Some(GoalType::Refactor)).unwrap(); +//! When a Run fails or the agent determines the plan needs revision, +//! a new [`Plan`](super::plan::Plan) revision is created. The **Task +//! stays the same** — it is the stable identity for the work. Only +//! the strategy (Plan) evolves: //! -//! task.add_constraint("Must use JWT"); -//! task.add_acceptance_criterion("All tests pass"); +//! ```text +//! Task (constant) Intent (constant, plan updated) +//! │ └─ plan ──▶ Plan_v2 (latest) +//! └─ runs: +//! Run₀ ──plan──▶ Plan_v1 (snapshot: original plan) +//! Run₁ ──plan──▶ Plan_v2 (snapshot: revised plan) //! ``` +//! +//! # Purpose +//! +//! - **Stable Identity**: The Task persists across retries and +//! replanning. All Runs, regardless of which Plan version they +//! executed, belong to the same Task. +//! - **Scope Definition**: `constraints` and `acceptance_criteria` +//! define what the agent must and must not do, and how success is +//! measured. +//! - **Hierarchy**: `parent` enables recursive decomposition — a +//! PlanStep can spawn a sub-Task, which in turn has its own Plan +//! and Runs. +//! - **Dependency Management**: `dependencies` enables ordering +//! between sibling Tasks (e.g. "implement API before writing +//! tests"). use std::{fmt, str::FromStr}; @@ -46,19 +86,27 @@ use crate::{ }, }; -/// Task lifecycle status. +/// Lifecycle status of a [`Task`]. +/// +/// See module docs for the status transition diagram. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TaskStatus { - /// Initial state, definition in progress. + /// Initial state. Task definition is in progress — title, + /// constraints, and acceptance criteria may still be changing. Draft, - /// Agent is actively working on this task. + /// An agent (via a [`Run`](super::run::Run)) is actively working + /// on this Task. At least one Run in `Task.runs` is active. Running, - /// Task completed successfully. + /// Task completed successfully. All acceptance criteria met and + /// the final PatchSet has been committed. Done, - /// Task failed to complete. + /// Task failed to complete after all retry attempts. The + /// [`Decision`](super::decision::Decision) of the last Run + /// explains the failure. Failed, - /// Task was cancelled by user. + /// Task was cancelled by the user or orchestrator before + /// completion (e.g. timeout, budget exceeded, user interrupt). Cancelled, } @@ -80,9 +128,11 @@ impl fmt::Display for TaskStatus { } } -/// Task goal category. +/// Classification of the work a [`Task`] aims to accomplish. /// -/// Helps agents understand the nature of the work. +/// Helps agents choose appropriate strategies and tools. For example, +/// a `Bugfix` task might prioritize reading test output, while a +/// `Refactor` task might focus on code structure analysis. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GoalType { @@ -96,10 +146,13 @@ pub enum GoalType { Build, Ci, Style, + /// Catch-all for goal categories not covered by the predefined + /// variants. The inner string is the custom category name. + Other(String), } impl GoalType { - pub fn as_str(&self) -> &'static str { + pub fn as_str(&self) -> &str { match self { GoalType::Feature => "feature", GoalType::Bugfix => "bugfix", @@ -111,6 +164,7 @@ impl GoalType { GoalType::Build => "build", GoalType::Ci => "ci", GoalType::Style => "style", + GoalType::Other(s) => s.as_str(), } } } @@ -136,30 +190,111 @@ impl FromStr for GoalType { "build" => Ok(GoalType::Build), "ci" => Ok(GoalType::Ci), "style" => Ok(GoalType::Style), - _ => Err(format!("Invalid goal_type: {}", value)), + _ => Ok(GoalType::Other(value.to_string())), } } } -/// Task object describing intent and constraints. -/// Typically created first, then referenced by Run objects. +/// A unit of work with constraints and success criteria. /// -/// See module documentation for lifecycle details. +/// A Task can be **top-level** (created directly from a user request) +/// or a **sub-Task** (spawned by a [`PlanStep`](super::plan::PlanStep) +/// for recursive decomposition). It is step ④ in the end-to-end flow. +/// See module documentation for lifecycle, relationships, and +/// replanning semantics. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Task { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, + /// Short human-readable summary of the work to be done. + /// + /// Analogous to a git commit subject line or a Jira ticket title. + /// Should be concise (under 100 characters) and describe the + /// desired outcome, not the method. Set once at creation. title: String, + /// Extended description providing additional context. + /// + /// May include background information, links to relevant docs or + /// issues, and any details that don't fit in `title`. `None` when + /// the title is self-explanatory. + #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, - goal_type: Option, - #[serde(default)] + /// Classification of the work (Feature, Bugfix, Refactor, etc.). + /// + /// Helps agents choose appropriate strategies. For example, a + /// `Bugfix` task might prioritize reading test output, while a + /// `Docs` task focuses on documentation files. `None` when the + /// category is unclear or not relevant. + #[serde(default, skip_serializing_if = "Option::is_none")] + goal: Option, + /// Hard constraints the solution must satisfy. + /// + /// Each entry is a natural-language rule (e.g. "Must use JWT", + /// "No breaking API changes", "Keep backward compatibility with + /// v2"). The agent must verify all constraints are met before + /// marking the Task as `Done`. Empty when there are no constraints. + #[serde(default, skip_serializing_if = "Vec::is_empty")] constraints: Vec, - #[serde(default)] + /// Criteria that must be met for the Task to be considered done. + /// + /// Each entry is a testable condition (e.g. "All tests pass", + /// "Coverage >= 80%", "No clippy warnings"). The + /// [`Evidence`](super::evidence::Evidence) produced during a Run + /// should demonstrate that these criteria are satisfied. Empty + /// when success is implied (e.g. "just do it"). + #[serde(default, skip_serializing_if = "Vec::is_empty")] acceptance_criteria: Vec, - requested_by: Option, - intent_id: Option, - #[serde(default)] + /// The actor who requested this work. + /// + /// May differ from `created_by` in the header when an agent + /// creates a Task on behalf of a user. For example, the + /// orchestrator (`created_by = system`) might create a Task + /// requested by a human (`requester = human`). `None` when the + /// requester is the same as the creator. + #[serde(default, skip_serializing_if = "Option::is_none")] + requester: Option, + /// Parent Task that spawned this sub-Task. + /// + /// Provides O(1) reverse navigation from a sub-Task back to its + /// parent. Set when a [`PlanStep`](super::plan::PlanStep) creates + /// a sub-Task via `PlanStep.task`. `None` for top-level Tasks. + /// + /// The forward direction is `PlanStep.task → sub-Task`; this field + /// is the corresponding back-reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + parent: Option, + /// Back-reference to the [`Intent`](super::intent::Intent) that + /// motivated this Task. + /// + /// Provides O(1) reverse navigation from work unit to the + /// originating user request: + /// - **Top-level Task**: points to the root Intent. + /// - **Sub-Task with own analysis**: points to a new sub-Intent. + /// - **Sub-Task (pure delegation)**: `None` — context is already + /// captured in the parent PlanStep's `iframes`/`oframes`. + #[serde(default, skip_serializing_if = "Option::is_none")] + intent: Option, + /// Chronological list of [`Run`](super::run::Run) IDs that have + /// executed (or are executing) this Task. + /// + /// Append-only — each new Run is pushed to the end. The last + /// entry is the most recent attempt. A Task may have multiple + /// Runs due to retries (after a `Decision::Retry`) or parallel + /// execution experiments. Empty when no Run has been created yet. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + runs: Vec, + /// Other Tasks that must complete before this one can start. + /// + /// Used for ordering sibling Tasks within a Plan (e.g. "implement + /// API" must complete before "write integration tests"). Empty + /// when there are no ordering constraints. + #[serde(default, skip_serializing_if = "Vec::is_empty")] dependencies: Vec, + /// Current lifecycle status. + /// + /// Updated by the orchestrator as the Task progresses. See + /// [`TaskStatus`] for valid transitions and semantics. status: TaskStatus, } @@ -167,25 +302,25 @@ impl Task { /// Create a new Task. /// /// # Arguments - /// * `repo_id` - Repository UUID /// * `created_by` - Actor creating the task /// * `title` - Short summary of the task - /// * `goal_type` - Optional classification (Feature, Bugfix, etc.) + /// * `goal` - Optional classification (Feature, Bugfix, etc.) pub fn new( - repo_id: Uuid, created_by: ActorRef, title: impl Into, - goal_type: Option, + goal: Option, ) -> Result { Ok(Self { - header: Header::new(ObjectType::Task, repo_id, created_by)?, + header: Header::new(ObjectType::Task, created_by)?, title: title.into(), description: None, - goal_type, + goal, constraints: Vec::new(), acceptance_criteria: Vec::new(), - requested_by: None, - intent_id: None, + requester: None, + parent: None, + intent: None, + runs: Vec::new(), dependencies: Vec::new(), status: TaskStatus::Draft, }) @@ -203,8 +338,8 @@ impl Task { self.description.as_deref() } - pub fn goal_type(&self) -> Option<&GoalType> { - self.goal_type.as_ref() + pub fn goal(&self) -> Option<&GoalType> { + self.goal.as_ref() } pub fn constraints(&self) -> &[String] { @@ -215,12 +350,22 @@ impl Task { &self.acceptance_criteria } - pub fn requested_by(&self) -> Option<&ActorRef> { - self.requested_by.as_ref() + pub fn requester(&self) -> Option<&ActorRef> { + self.requester.as_ref() + } + + /// Returns the parent Task ID, if this is a sub-Task. + pub fn parent(&self) -> Option { + self.parent + } + + pub fn intent(&self) -> Option { + self.intent } - pub fn intent_id(&self) -> Option { - self.intent_id + /// Returns the chronological list of Run IDs for this task. + pub fn runs(&self) -> &[Uuid] { + &self.runs } pub fn dependencies(&self) -> &[Uuid] { @@ -243,12 +388,21 @@ impl Task { self.acceptance_criteria.push(criterion.into()); } - pub fn set_requested_by(&mut self, requested_by: Option) { - self.requested_by = requested_by; + pub fn set_requester(&mut self, requester: Option) { + self.requester = requester; } - pub fn set_intent_id(&mut self, intent_id: Option) { - self.intent_id = intent_id; + pub fn set_parent(&mut self, parent: Option) { + self.parent = parent; + } + + pub fn set_intent(&mut self, intent: Option) { + self.intent = intent; + } + + /// Appends a Run ID to the execution history. + pub fn add_run(&mut self, run_id: Uuid) { + self.runs.push(run_id); } pub fn add_dependency(&mut self, task_id: Uuid) { @@ -300,9 +454,8 @@ mod tests { #[test] fn test_task_creation() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); - let mut task = Task::new(repo_id, actor, "Fix bug", Some(GoalType::Bugfix)).expect("task"); + let mut task = Task::new(actor, "Fix bug", Some(GoalType::Bugfix)).expect("task"); // Test dependencies let dep_id = Uuid::from_u128(0x00000000000000000000000000000001); @@ -310,31 +463,76 @@ mod tests { assert_eq!(task.header().object_type(), &ObjectType::Task); assert_eq!(task.status(), &TaskStatus::Draft); - assert_eq!(task.goal_type(), Some(&GoalType::Bugfix)); + assert_eq!(task.goal(), Some(&GoalType::Bugfix)); assert_eq!(task.dependencies().len(), 1); assert_eq!(task.dependencies()[0], dep_id); - assert!(task.intent_id().is_none()); + assert!(task.intent().is_none()); } #[test] - fn test_task_goal_type_optional() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); + fn test_task_goal_optional() { let actor = ActorRef::human("jackie").expect("actor"); - let task = Task::new(repo_id, actor, "Write docs", None).expect("task"); + let task = Task::new(actor, "Write docs", None).expect("task"); - assert!(task.goal_type().is_none()); + assert!(task.goal().is_none()); } #[test] - fn test_task_requested_by() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); + fn test_task_requester() { let actor = ActorRef::human("jackie").expect("actor"); - let mut task = - Task::new(repo_id, actor.clone(), "Fix bug", Some(GoalType::Bugfix)).expect("task"); + let mut task = Task::new(actor.clone(), "Fix bug", Some(GoalType::Bugfix)).expect("task"); + + task.set_requester(Some(ActorRef::mcp_client("vscode-client").expect("actor"))); - task.set_requested_by(Some(ActorRef::mcp_client("vscode-client").expect("actor"))); + assert!(task.requester().is_some()); + assert_eq!(task.requester().unwrap().kind(), &ActorKind::McpClient); + } - assert!(task.requested_by().is_some()); - assert_eq!(task.requested_by().unwrap().kind(), &ActorKind::McpClient); + #[test] + fn test_task_runs() { + let actor = ActorRef::human("jackie").expect("actor"); + let mut task = Task::new(actor, "Fix bug", Some(GoalType::Bugfix)).expect("task"); + + assert!(task.runs().is_empty()); + + let run1 = Uuid::from_u128(0x10); + let run2 = Uuid::from_u128(0x20); + task.add_run(run1); + task.add_run(run2); + + assert_eq!(task.runs(), &[run1, run2]); + } + + #[test] + fn test_task_from_bytes_without_header_version() { + // Old format data without header_version — should still parse + let json = serde_json::json!({ + "object_id": "01234567-89ab-cdef-0123-456789abcdef", + "object_type": "task", + "schema_version": 1, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "created_by": {"kind": "human", "id": "jackie"}, + "visibility": "private", + "title": "old task", + "status": "draft" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let task = + Task::from_bytes(&bytes, ObjectHash::default()).expect("should parse old format"); + assert_eq!(task.title(), "old task"); + assert_eq!(task.header().header_version(), 1); + } + + #[test] + fn test_task_serialization_includes_header_version() { + let actor = ActorRef::human("jackie").expect("actor"); + let task = Task::new(actor, "New task", None).expect("task"); + let data = task.to_data().expect("serialize"); + let value: serde_json::Value = serde_json::from_slice(&data).unwrap(); + assert_eq!( + value["header_version"], + crate::internal::object::types::CURRENT_HEADER_VERSION + ); } } diff --git a/src/internal/object/tool.rs b/src/internal/object/tool.rs index 912a109b..364a45c6 100644 --- a/src/internal/object/tool.rs +++ b/src/internal/object/tool.rs @@ -1,20 +1,38 @@ //! AI Tool Invocation Definition //! -//! A `ToolInvocation` records a specific action taken by an agent, such as reading a file, -//! running a command, or querying a search engine. +//! A `ToolInvocation` records a single action taken by an agent during +//! a [`Run`](super::run::Run) — reading a file, running a shell +//! command, calling an API, etc. It is the finest-grained unit of +//! agent activity, capturing *what* was done, *with which arguments*, +//! and *what happened*. //! -//! # Purpose +//! # Position in Lifecycle +//! +//! ```text +//! Run ──patchsets──▶ [PatchSet₀, ...] +//! │ +//! ├── evidence ──▶ [Evidence₀, ...] +//! │ +//! └── tool invocations ──▶ [ToolInvocation₀, ToolInvocation₁, ...] +//! │ +//! └── io_footprint (paths read/written) +//! ``` //! -//! - **Audit Trail**: Allows reconstructing exactly what the agent did. -//! - **Cost Tracking**: Can be used to calculate token/resource usage. -//! - **Debugging**: Helps understand why an agent made a particular decision. +//! ToolInvocations are produced **throughout** a Run, one per tool +//! call. They form a chronological log of every action the agent +//! performed. Unlike Evidence (which validates a PatchSet) or +//! Decision (which concludes a Run), ToolInvocations are low-level +//! operational records. //! -//! # Fields +//! # Purpose //! -//! - `tool_name`: The identifier of the tool (e.g., "read_file"). -//! - `args`: JSON arguments passed to the tool. -//! - `io_footprint`: Files read/written during the operation (for dependency tracking). -//! - `status`: Whether the tool call succeeded or failed. +//! - **Audit Trail**: Allows reconstructing exactly what the agent did +//! step by step, including arguments and results. +//! - **Dependency Tracking**: `io_footprint` records which files were +//! read or written, enabling incremental re-runs and cache +//! invalidation. +//! - **Debugging**: When a Run produces unexpected results, reviewing +//! the ToolInvocation sequence reveals the agent's reasoning path. use std::fmt; @@ -55,42 +73,91 @@ impl fmt::Display for ToolStatus { } } -/// IO footprint of a tool invocation. -/// Tracks reads and writes for auditability. +/// File-level I/O footprint of a tool invocation. +/// +/// Records which files were read and written during the tool call. +/// Used for dependency tracking (which inputs influenced which +/// outputs) and for cache invalidation on incremental re-runs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IoFootprint { - #[serde(default)] + /// Paths the tool read during execution (e.g. source files, + /// config files). Relative to the repository root. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paths_read: Vec, - #[serde(default)] + /// Paths the tool wrote or modified (e.g. generated files, + /// patch output). Relative to the repository root. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paths_written: Vec, } -/// Tool invocation record. -/// Records a single tool call within a run. +/// Record of a single tool call made by an agent during a Run. +/// +/// One ToolInvocation per tool call. The chronological sequence of +/// ToolInvocations within a Run forms the agent's action log. See +/// module documentation for lifecycle position. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolInvocation { + /// Common header (object ID, type, timestamps, creator, etc.). #[serde(flatten)] header: Header, + /// The [`Run`](super::run::Run) during which this tool was called. + /// + /// Every ToolInvocation belongs to exactly one Run. The Run does + /// not store a back-reference; the full invocation log is + /// reconstructed by querying all ToolInvocations with the same + /// `run_id`, ordered by `created_at`. run_id: Uuid, + /// Identifier of the tool that was called (e.g. "read_file", + /// "bash", "search_code"). + /// + /// This is the tool's registered name in the agent's tool + /// catalogue, not a human-readable label. tool_name: String, + /// Files read and written during this tool call. + /// + /// `None` when the tool has no file-system side effects (e.g. a + /// pure computation or API call). See [`IoFootprint`] for details. + #[serde(default, skip_serializing_if = "Option::is_none")] io_footprint: Option, + /// Arguments passed to the tool, as a JSON value. + /// + /// The schema depends on the tool. For example, `read_file` might + /// have `{"path": "src/main.rs"}`, while `bash` might have + /// `{"command": "cargo test"}`. `Null` when the tool takes no + /// arguments. #[serde(default)] args: serde_json::Value, + /// Whether the tool call succeeded or failed. + /// + /// `Ok` means the tool returned a normal result; `Error` means it + /// returned an error. The orchestrator may use this to decide + /// whether to retry or abort the Run. status: ToolStatus, + /// Short human-readable summary of the tool's output. + /// + /// For `read_file`: might be the file size or first few lines. + /// For `bash`: might be the last line of stdout. For failed calls: + /// the error message. `None` if no summary was captured. For full + /// output, see `artifacts`. + #[serde(default, skip_serializing_if = "Option::is_none")] result_summary: Option, - #[serde(default)] + /// References to full output files in object storage. + /// + /// May include stdout/stderr logs, generated files, screenshots, + /// etc. Each [`ArtifactRef`] points to one stored file. Empty + /// when the tool produced no persistent output. + #[serde(default, skip_serializing_if = "Vec::is_empty")] artifacts: Vec, } impl ToolInvocation { pub fn new( - repo_id: Uuid, created_by: ActorRef, run_id: Uuid, tool_name: impl Into, ) -> Result { Ok(Self { - header: Header::new(ObjectType::ToolInvocation, repo_id, created_by)?, + header: Header::new(ObjectType::ToolInvocation, created_by)?, run_id, tool_name: tool_name.into(), io_footprint: None, @@ -193,12 +260,11 @@ mod tests { #[test] fn test_tool_invocation_io_footprint() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); let run_id = Uuid::from_u128(0x1); let mut tool_inv = - ToolInvocation::new(repo_id, actor, run_id, "read_file").expect("tool_invocation"); + ToolInvocation::new(actor, run_id, "read_file").expect("tool_invocation"); let footprint = IoFootprint { paths_read: vec!["src/main.rs".to_string()], @@ -217,12 +283,11 @@ mod tests { #[test] fn test_tool_invocation_fields() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); let run_id = Uuid::from_u128(0x1); let mut tool_inv = - ToolInvocation::new(repo_id, actor, run_id, "apply_patch").expect("tool_invocation"); + ToolInvocation::new(actor, run_id, "apply_patch").expect("tool_invocation"); tool_inv.set_status(ToolStatus::Error); tool_inv.set_args(serde_json::json!({"path": "src/lib.rs"})); tool_inv.set_result_summary(Some("failed".to_string())); diff --git a/src/internal/object/types.rs b/src/internal/object/types.rs index 98d6f863..00faf536 100644 --- a/src/internal/object/types.rs +++ b/src/internal/object/types.rs @@ -64,6 +64,7 @@ pub enum ObjectType { Task, Intent, ToolInvocation, + ContextPipeline, } const COMMIT_OBJECT_TYPE: &[u8] = b"commit"; @@ -80,6 +81,7 @@ const RUN_OBJECT_TYPE: &[u8] = b"run"; const TASK_OBJECT_TYPE: &[u8] = b"task"; const INTENT_OBJECT_TYPE: &[u8] = b"intent"; const TOOL_INVOCATION_OBJECT_TYPE: &[u8] = b"invocation"; +const CONTEXT_PIPELINE_OBJECT_TYPE: &[u8] = b"pipeline"; /// Display trait for Git objects type impl Display for ObjectType { @@ -102,6 +104,7 @@ impl Display for ObjectType { ObjectType::Task => write!(f, "task"), ObjectType::Intent => write!(f, "intent"), ObjectType::ToolInvocation => write!(f, "invocation"), + ObjectType::ContextPipeline => write!(f, "pipeline"), } } } @@ -145,23 +148,29 @@ impl ObjectType { } } - pub fn to_bytes(&self) -> &[u8] { + /// Returns the loose-object type header bytes (e.g. `b"commit"`, `b"blob"`). + /// + /// Delta types (`OffsetDelta`, `HashDelta`, `OffsetZstdelta`) only + /// exist inside pack files and have no loose-object representation. + /// Passing a delta type is a logic error and returns `None`. + pub fn to_bytes(&self) -> Option<&[u8]> { match self { - ObjectType::Commit => COMMIT_OBJECT_TYPE, - ObjectType::Tree => TREE_OBJECT_TYPE, - ObjectType::Blob => BLOB_OBJECT_TYPE, - ObjectType::Tag => TAG_OBJECT_TYPE, - ObjectType::ContextSnapshot => CONTEXT_SNAPSHOT_OBJECT_TYPE, - ObjectType::Decision => DECISION_OBJECT_TYPE, - ObjectType::Evidence => EVIDENCE_OBJECT_TYPE, - ObjectType::PatchSet => PATCH_SET_OBJECT_TYPE, - ObjectType::Plan => PLAN_OBJECT_TYPE, - ObjectType::Provenance => PROVENANCE_OBJECT_TYPE, - ObjectType::Run => RUN_OBJECT_TYPE, - ObjectType::Task => TASK_OBJECT_TYPE, - ObjectType::Intent => INTENT_OBJECT_TYPE, - ObjectType::ToolInvocation => TOOL_INVOCATION_OBJECT_TYPE, - _ => panic!("can put compute the delta hash value"), + ObjectType::Commit => Some(COMMIT_OBJECT_TYPE), + ObjectType::Tree => Some(TREE_OBJECT_TYPE), + ObjectType::Blob => Some(BLOB_OBJECT_TYPE), + ObjectType::Tag => Some(TAG_OBJECT_TYPE), + ObjectType::ContextSnapshot => Some(CONTEXT_SNAPSHOT_OBJECT_TYPE), + ObjectType::Decision => Some(DECISION_OBJECT_TYPE), + ObjectType::Evidence => Some(EVIDENCE_OBJECT_TYPE), + ObjectType::PatchSet => Some(PATCH_SET_OBJECT_TYPE), + ObjectType::Plan => Some(PLAN_OBJECT_TYPE), + ObjectType::Provenance => Some(PROVENANCE_OBJECT_TYPE), + ObjectType::Run => Some(RUN_OBJECT_TYPE), + ObjectType::Task => Some(TASK_OBJECT_TYPE), + ObjectType::Intent => Some(INTENT_OBJECT_TYPE), + ObjectType::ToolInvocation => Some(TOOL_INVOCATION_OBJECT_TYPE), + ObjectType::ContextPipeline => Some(CONTEXT_PIPELINE_OBJECT_TYPE), + ObjectType::OffsetDelta | ObjectType::HashDelta | ObjectType::OffsetZstdelta => None, } } @@ -182,6 +191,7 @@ impl ObjectType { "task" => Ok(ObjectType::Task), "intent" => Ok(ObjectType::Intent), "invocation" => Ok(ObjectType::ToolInvocation), + "pipeline" => Ok(ObjectType::ContextPipeline), _ => Err(GitError::InvalidObjectType(s.to_string())), } } @@ -207,6 +217,7 @@ impl ObjectType { ObjectType::ToolInvocation => Ok(vec![ 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, ]), // invocation + ObjectType::ContextPipeline => Ok(vec![0x70, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65]), // pipeline _ => Err(GitError::InvalidObjectType(self.to_string())), } } @@ -231,6 +242,7 @@ impl ObjectType { ObjectType::Task => 15, ObjectType::Intent => 16, ObjectType::ToolInvocation => 17, + ObjectType::ContextPipeline => 18, } } @@ -254,32 +266,22 @@ impl ObjectType { 15 => Ok(ObjectType::Task), 16 => Ok(ObjectType::Intent), 17 => Ok(ObjectType::ToolInvocation), + 18 => Ok(ObjectType::ContextPipeline), _ => Err(GitError::InvalidObjectType(format!( "Invalid object type number: {number}" ))), } } + /// Returns `true` if this type is a base Git object that can appear + /// as a delta target in pack files. AI object types return `false` + /// because they cannot be encoded in pack files and should never + /// participate in delta window selection. pub fn is_base(&self) -> bool { - match self { - ObjectType::Commit => true, - ObjectType::Tree => true, - ObjectType::Blob => true, - ObjectType::Tag => true, - ObjectType::HashDelta => false, - ObjectType::OffsetZstdelta => false, - ObjectType::OffsetDelta => false, - ObjectType::ContextSnapshot => true, - ObjectType::Decision => true, - ObjectType::Evidence => true, - ObjectType::PatchSet => true, - ObjectType::Plan => true, - ObjectType::Provenance => true, - ObjectType::Run => true, - ObjectType::Task => true, - ObjectType::Intent => true, - ObjectType::ToolInvocation => true, - } + matches!( + self, + ObjectType::Commit | ObjectType::Tree | ObjectType::Blob | ObjectType::Tag + ) } /// Returns `true` if this type is an AI extension object (not representable @@ -297,6 +299,7 @@ impl ObjectType { | ObjectType::Task | ObjectType::Intent | ObjectType::ToolInvocation + | ObjectType::ContextPipeline ) } } @@ -557,10 +560,13 @@ impl ArtifactRef { } } -fn default_updated_at() -> DateTime { - Utc::now() +fn default_header_version() -> u32 { + 1 } +/// Current header format version for newly created objects. +pub const CURRENT_HEADER_VERSION: u32 = 1; + /// Header shared by all AI Process Objects. /// /// Contains standard metadata like ID, type, creator, and timestamps. @@ -571,26 +577,32 @@ fn default_updated_at() -> DateTime { /// /// ```rust,ignore /// #[derive(Serialize, Deserialize)] -/// pub struct MyObject { +/// pub struct AIObject { /// #[serde(flatten)] /// header: Header, /// // specific fields... /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct Header { /// Global unique ID (UUID v7) object_id: Uuid, /// Object type (task/run/patchset/...) object_type: ObjectType, - /// Model version + /// Format version of the Header struct itself. + /// Defaults to 1 when deserializing old data that lacks this field. + #[serde(default = "default_header_version")] + header_version: u32, + /// Per-object-type schema version for body fields. schema_version: u32, - /// Repository identifier - repo_id: Uuid, /// Creation time created_at: DateTime, - #[serde(default = "default_updated_at")] + /// Last modification time. + /// + /// When deserializing legacy data that lacks this field, falls back + /// to `created_at` for deterministic behavior (see custom + /// `Deserialize` impl below). updated_at: DateTime, /// Creator created_by: ActorRef, @@ -607,25 +619,65 @@ pub struct Header { checksum: Option, } +/// Custom `Deserialize` for [`Header`] so that a missing `updated_at` +/// falls back to `created_at` instead of `Utc::now()`. This avoids +/// nondeterministic metadata when loading legacy objects that predate +/// the `updated_at` field. +impl<'de> Deserialize<'de> for Header { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawHeader { + object_id: Uuid, + object_type: ObjectType, + #[serde(default = "default_header_version")] + header_version: u32, + schema_version: u32, + created_at: DateTime, + updated_at: Option>, + created_by: ActorRef, + visibility: Visibility, + #[serde(default)] + tags: HashMap, + #[serde(default)] + external_ids: HashMap, + #[serde(default)] + checksum: Option, + } + + let raw = RawHeader::deserialize(deserializer)?; + Ok(Header { + object_id: raw.object_id, + object_type: raw.object_type, + header_version: raw.header_version, + schema_version: raw.schema_version, + created_at: raw.created_at, + updated_at: raw.updated_at.unwrap_or(raw.created_at), + created_by: raw.created_by, + visibility: raw.visibility, + tags: raw.tags, + external_ids: raw.external_ids, + checksum: raw.checksum, + }) + } +} + impl Header { /// Create a new Header with default values. /// /// # Arguments /// /// * `object_type` - The specific type of the AI object. - /// * `repo_id` - The UUID of the repository this object belongs to. /// * `created_by` - The actor (human/agent) creating this object. - pub fn new( - object_type: ObjectType, - repo_id: Uuid, - created_by: ActorRef, - ) -> Result { + pub fn new(object_type: ObjectType, created_by: ActorRef) -> Result { let now = Utc::now(); Ok(Self { object_id: Uuid::now_v7(), object_type, + header_version: CURRENT_HEADER_VERSION, schema_version: 1, - repo_id, created_at: now, updated_at: now, created_by, @@ -644,12 +696,12 @@ impl Header { &self.object_type } - pub fn schema_version(&self) -> u32 { - self.schema_version + pub fn header_version(&self) -> u32 { + self.header_version } - pub fn repo_id(&self) -> Uuid { - self.repo_id + pub fn schema_version(&self) -> u32 { + self.schema_version } pub fn created_at(&self) -> DateTime { @@ -693,6 +745,14 @@ impl Header { Ok(()) } + pub fn set_header_version(&mut self, header_version: u32) -> Result<(), String> { + if header_version == 0 { + return Err("header_version must be greater than 0".to_string()); + } + self.header_version = header_version; + Ok(()) + } + pub fn set_schema_version(&mut self, schema_version: u32) -> Result<(), String> { if schema_version == 0 { return Err("schema_version must be greater than 0".to_string()); @@ -720,6 +780,8 @@ impl Header { /// Seal the header by calculating and setting the checksum of the provided object. /// The checksum field is temporarily cleared to keep sealing idempotent. + /// Also updates `updated_at` to the current time, since sealing + /// represents a semantic modification of the object. /// /// This is typically called just before storing the object to ensure `checksum` matches content. pub fn seal(&mut self, object: &T) -> Result<(), serde_json::Error> { @@ -727,6 +789,7 @@ impl Header { match compute_integrity_hash(object) { Ok(checksum) => { self.checksum = Some(checksum); + self.updated_at = Utc::now(); Ok(()) } Err(err) => { @@ -813,16 +876,50 @@ mod tests { #[test] fn test_header_serialization() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); - let header = Header::new(ObjectType::Task, repo_id, actor).expect("header"); + let header = Header::new(ObjectType::Task, actor).expect("header"); let json = serde_json::to_string(&header).unwrap(); let deserialized: Header = serde_json::from_str(&json).unwrap(); assert_eq!(header.object_id(), deserialized.object_id()); assert_eq!(header.object_type(), deserialized.object_type()); - assert_eq!(header.repo_id(), deserialized.repo_id()); + assert_eq!(header.header_version(), deserialized.header_version()); + } + + #[test] + fn test_header_version_new_uses_current() { + let actor = ActorRef::human("jackie").expect("actor"); + let header = Header::new(ObjectType::Task, actor).expect("header"); + assert_eq!( + header.header_version(), + crate::internal::object::types::CURRENT_HEADER_VERSION + ); + } + + #[test] + fn test_header_version_defaults_on_missing() { + // Simulate old serialized data without header_version + let json = r#"{ + "object_id": "01234567-89ab-cdef-0123-456789abcdef", + "object_type": "task", + "schema_version": 1, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "created_by": {"kind": "human", "id": "jackie"}, + "visibility": "private" + }"#; + let header: Header = serde_json::from_str(json).unwrap(); + assert_eq!(header.header_version(), 1); + } + + #[test] + fn test_header_version_setter_rejects_zero() { + let actor = ActorRef::human("jackie").expect("actor"); + let mut header = Header::new(ObjectType::Task, actor).expect("header"); + assert!(header.set_header_version(0).is_err()); + assert!(header.set_header_version(3).is_ok()); + assert_eq!(header.header_version(), 3); } #[test] @@ -854,9 +951,8 @@ mod tests { #[test] fn test_header_checksum() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); - let mut header = Header::new(ObjectType::Task, repo_id, actor).expect("header"); + let mut header = Header::new(ObjectType::Task, actor).expect("header"); // Fix time for deterministic checksum header.set_created_at( DateTime::parse_from_rfc3339("2026-02-10T00:00:00Z") @@ -917,9 +1013,8 @@ mod tests { #[test] fn test_header_seal() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); - let mut header = Header::new(ObjectType::Task, repo_id, actor).expect("header"); + let mut header = Header::new(ObjectType::Task, actor).expect("header"); let content = serde_json::json!({"key": "value"}); header.seal(&content).expect("seal"); @@ -932,9 +1027,8 @@ mod tests { #[test] fn test_header_updated_at_on_seal() { - let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef); let actor = ActorRef::human("jackie").expect("actor"); - let mut header = Header::new(ObjectType::Task, repo_id, actor).expect("header"); + let mut header = Header::new(ObjectType::Task, actor).expect("header"); let before = header.updated_at(); let content = serde_json::json!({"key": "value"}); diff --git a/src/internal/pack/utils.rs b/src/internal/pack/utils.rs index 58a28f4c..3890df81 100644 --- a/src/internal/pack/utils.rs +++ b/src/internal/pack/utils.rs @@ -267,11 +267,14 @@ pub fn read_delta_object_size(stream: &mut R) -> io::Result<(usize, usi ///
"` \0`" ///
data: The decompressed content of the object pub fn calculate_object_hash(obj_type: ObjectType, data: &Vec) -> ObjectHash { + let type_bytes = obj_type + .to_bytes() + .expect("calculate_object_hash called with a delta type that has no loose-object header"); match get_hash_kind() { crate::hash::HashKind::Sha1 => { let mut hash = Sha1::new(); // Header: " \0" - hash.update(obj_type.to_bytes()); + hash.update(type_bytes); hash.update(b" "); hash.update(data.len().to_string()); hash.update(b"\0"); @@ -285,7 +288,7 @@ pub fn calculate_object_hash(obj_type: ObjectType, data: &Vec) -> ObjectHash crate::hash::HashKind::Sha256 => { let mut hash = sha2::Sha256::new(); // Header: " \0" - hash.update(obj_type.to_bytes()); + hash.update(type_bytes); hash.update(b" "); hash.update(data.len().to_string()); hash.update(b"\0"); diff --git a/src/internal/zlib/stream/inflate.rs b/src/internal/zlib/stream/inflate.rs index 058132d9..94aaef42 100644 --- a/src/internal/zlib/stream/inflate.rs +++ b/src/internal/zlib/stream/inflate.rs @@ -32,7 +32,11 @@ where pub fn new(inner: R, obj_type: ObjectType, size: usize) -> Self { // Initialize the hash with the object header. let mut hash = HashAlgorithm::new(); - hash.update(obj_type.to_bytes()); + hash.update( + obj_type + .to_bytes() + .expect("ReadBoxed::new called with a delta type"), + ); hash.update(b" "); hash.update(size.to_string().as_bytes()); hash.update(b"\0"); @@ -143,7 +147,7 @@ mod tests { // Expected hash: header "blob \\0" + body let mut expected = Sha1::new(); - expected.update(ObjectType::Blob.to_bytes()); + expected.update(ObjectType::Blob.to_bytes().unwrap()); expected.update(b" "); expected.update(body.len().to_string()); expected.update(b"\0");