diff --git a/.gitignore b/.gitignore index 01f499d..c2ceffc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,48 @@ -__pycache__ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Env and secrets (do not commit API keys) +.env +.env.local +.env.*.local + +# IDE / editor .claude +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs and local outputs +*.log +.oape-output/ diff --git a/README.md b/README.md index 6f1316d..63100a9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ ln -s oape-ai-e2e ~/.cursor/commands/oape-ai-e2e | ------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- | | **[oape](plugins/oape/)** | AI-driven OpenShift operator development tools | `/oape:init`, `/oape:api-generate`, `/oape:api-generate-tests`, `/oape:api-implement`, `/oape:analyze-rfe`, `/oape:e2e-generate`, `/oape:review`, `/oape:implement-review-fixes` | +## CrewAI Multi-Agent Workflow + +A **project-agnostic** CrewAI setup lives in **[crewai/](crewai/)**. It runs a 9-task pipeline (design → design review → test cases → implementation outline → quality → code review → address review → write-up → customer doc) with four agents (SSE, PSE, SQE, Technical Writer). Agents take learnings from **skills** in `plugins/oape/skills/` (e.g. Effective Go). Scope is set at runtime via env or CLI—no project-specific context. See [crewai/README.md](crewai/README.md) for setup and usage. + ## Commands ### `/oape:init` -- Clone an Operator Repository diff --git a/crewai/README.md b/crewai/README.md new file mode 100644 index 0000000..a98a9d2 --- /dev/null +++ b/crewai/README.md @@ -0,0 +1,277 @@ +# OAPE Workflow (Adapter: CrewAI vs Claude SDK) + +Project-agnostic workflow for OAPE with an **adapter design** so you can switch between two backends: + +| Backend | What it does | +|-------------|---------------| +| **crewai** | Full 11-task pipeline: design → design review → test plan → implementation outline → **unit tests (SQE)** → **implementation (SSE)** → quality → code review → address review → write-up → customer doc. Code must compile when using `--apply-to-repo`. Uses skills from `plugins/oape/skills/`. | +| **claude-sdk** | Calls the OAPE server (Claude Agent SDK) for **api-implement**: generate controller/reconciler code from an enhancement proposal. Requires server running and an EP URL. | + +Same **scope** (context file, EP URL, env) and same **entrypoint** (`main.py`); switch via `--backend` or `OAPE_BACKEND`. + +## Features + +- **Adapter pattern:** One interface (`WorkflowAdapter.run(scope) -> WorkflowResult`), two implementations; switch backends without changing caller code. +- **Project-agnostic:** Scope is set at runtime (env, CLI, context file, or EP URL). +- **Skills-driven (CrewAI):** All `plugins/oape/skills//SKILL.md` are loaded and injected when using the CrewAI backend. + +## Setup + +From the **oape-ai-e2e** repo root: + +```bash +cd crewai +pip install -r requirements.txt +``` + +Set scope via env or CLI (see below). For OpenAI (default), set `OPENAI_API_KEY`. For Vertex Claude, set `OAPE_CREWAI_USE_VERTEX=1` and Vertex env vars (`ANTHROPIC_VERTEX_PROJECT_ID`, `CLOUD_ML_REGION`, optional `VERTEX_CLAUDE_MODEL`). + +## Switching backends + +- **CrewAI (default):** `python main.py ...` or `OAPE_BACKEND=crewai python main.py ...` +- **Claude SDK:** Start the OAPE server (e.g. `cd server && uvicorn server:app`), set `OAPE_CLAUDE_SDK_SERVER_URL` if not `http://localhost:8000`, then: + ```bash + python main.py --backend claude-sdk --ep-url https://github.com/openshift/enhancements/pull/1234 --project-name "My Operator" --repo-url https://github.com/openshift/my-operator + ``` + The Claude SDK backend requires an EP URL (via `--ep-url` or `OAPE_EP_URL` or in the context file). Operator repo path: `OAPE_OPERATOR_CWD`. + +## Running + +**Using environment variables:** + +```bash +export OAPE_PROJECT_NAME="My Operator" +export OAPE_REPO_URL="https://github.com/openshift/my-operator" +export OAPE_SCOPE_DESCRIPTION="Add a new CRD and controller for Foo resource; follow controller-runtime." +# optional: +export OAPE_EXTRA_CONTEXT="Link to enhancement: https://github.com/openshift/enhancements/pull/1234" + +python main.py +``` + +**Using CLI:** + +```bash +python main.py \ + --project-name "My Operator" \ + --repo-url "https://github.com/openshift/my-operator" \ + --scope "Add a new CRD and controller for Foo resource; follow controller-runtime." +``` + +**Using a context file (e.g. scope.txt):** + +The file can be plain text (entire file = scope description) or use optional headers: + +```text +PROJECT_NAME=My Operator +REPO_URL=https://github.com/openshift/my-operator +--- +Add a new CRD and controller for Foo resource. Follow controller-runtime. +Include validation, reconciliation, and unit tests. +``` + +Then: + +```bash +python main.py --context-file path/to/scope.txt +``` + +**Using a local repo path (so the SSE uses real paths):** + +If you have a local clone, pass its path so the workflow injects the **directory layout** into scope. The design and implementation outline will then suggest only files/packages that exist in that tree (no invented directories). + +```bash +python main.py --context-file scope.txt --repo-path /path/to/openshift-zero-trust-workload-identity-manager +# or +export OAPE_REPO_PATH=/path/to/your-repo +python main.py --context-file scope.txt +``` + +`OAPE_OPERATOR_CWD` is also read as a fallback for the repo path. + +An example file is provided: `example_scope.txt`. You can also set `OAPE_CONTEXT_FILE=path/to/scope.txt` in the environment. If `PROJECT_NAME` and `REPO_URL` are omitted in the file, set them via env or CLI, or the default scope names are used. + +**Using a GitHub Enhancement Proposal (EP) URL:** + +Fetches the PR title and body from `openshift/enhancements` and adds it as extra context. Requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated (`gh auth login`). + +```bash +python main.py --ep-url https://github.com/openshift/enhancements/pull/1234 --project-name "My Operator" --repo-url https://github.com/openshift/my-operator +``` + +You can combine **context file** and **EP URL**: e.g. `--context-file scope.txt --ep-url https://github.com/openshift/enhancements/pull/1234` uses the file for project/scope and appends the EP content as additional context. Env vars `OAPE_CONTEXT_FILE` and `OAPE_EP_URL` can be used instead of CLI flags. + +If no scope is provided, a default example scope is used so the crew still runs (useful for testing). + +## How to test + +**Prerequisites (from `oape-ai-e2e/crewai`):** + +```bash +cd /path/to/oape-ai-e2e/crewai +pip install -r requirements.txt +export OPENAI_API_KEY=sk-... # or use Vertex: OAPE_CREWAI_USE_VERTEX=1 + ANTHROPIC_VERTEX_PROJECT_ID, CLOUD_ML_REGION +``` + +**Quick test script (from `crewai/`):** + +```bash +./scripts/test_crewai.sh # smoke test (default scope) +./scripts/test_crewai.sh context # with example_scope.txt +./scripts/test_crewai.sh output-dir # write outputs to /tmp/oape-test +REPO_PATH=/path/to/repo ./scripts/test_crewai.sh apply # apply to repo (branch + code + compile + commit) +``` + +**1. Minimal smoke test (default scope, 11-task pipeline)** + +No context file needed; uses built-in default scope. You should see reasoning, 11 tasks (design → review → test plan → outline → **unit tests** → **implementation** → quality → code review → address review → write-up → customer doc), and the TRACE section at the end. + +```bash +python main.py +``` + +**2. Test with a context file** + +```bash +python main.py --context-file example_scope.txt +``` + +**3. Test with ZTWIM scope and local repo** + +Point at a local operator clone so the workflow uses real paths. SQE writes unit tests first, then SSE writes implementation; if you use `--apply-to-repo`, the code must compile before commit. + +```bash +# With repo path only (no apply): +python main.py --context-file scope_ztwim_upstream_authority.txt --repo-path /path/to/your/operator-repo + +# Write all task outputs to a directory: +python main.py --context-file scope_ztwim_upstream_authority.txt --repo-path /path/to/repo --output-dir /tmp/oape-out + +# Apply to repo: new branch, write code, verify compile, commit (requires Go/make): +python main.py --context-file scope_ztwim_upstream_authority.txt --repo-path /path/to/repo --apply-to-repo +``` + +Optional: `--branch-name oape/my-feature` and `OAPE_OUTPUT_DIR`, `OAPE_APPLY_TO_REPO`, `OAPE_BRANCH_NAME` as env equivalents. + +**4. Avoid "prompt is too long" (200k token limit)** + +Task outputs are truncated when used as context for later tasks so the total prompt stays under the model limit. Default: 8000 characters per task output. Override if needed: + +```bash +OAPE_CONTEXT_MAX_CHARS_PER_TASK=10000 python main.py --context-file scope_ztwim_upstream_authority.txt --repo-path /path/to/repo +``` + +**5. More reasoning (debugging)** + +Agents use `max_reasoning_attempts=20` by default. Override: + +```bash +OAPE_MAX_REASONING_ATTEMPTS=20 python main.py --context-file example_scope.txt +``` + +**6. See full LLM request/response** + +```bash +CREWAI_DEBUG_LLM=1 python main.py --context-file example_scope.txt +``` + +**7. See full reasoning plan before each task** + +```bash +CREWAI_DEBUG_REASONING=1 python main.py --context-file example_scope.txt +``` + +**8. Test with CLI scope (no file)** + +```bash +python main.py --project-name "Test Operator" --repo-url "https://github.com/openshift/example" --scope "Add a simple CRD and reconciler for Bar resource." +``` + +**9. Claude SDK backend (requires OAPE server running)** + +In one terminal start the server (from `oape-ai-e2e`): + +```bash +cd server && uvicorn server:app --reload +``` + +In another, from `oape-ai-e2e/crewai`: + +```bash +python main.py --backend claude-sdk --ep-url https://github.com/openshift/enhancements/pull/1234 --project-name "My Operator" --repo-url https://github.com/openshift/my-operator +``` + +Set `OAPE_CLAUDE_SDK_SERVER_URL` if the server is not at `http://localhost:8000`. + +## Trace ID and dashboard + +To see **TraceID** and open the run in the CrewAI dashboard: + +1. **Enable tracing** + The CrewAI backend already uses `tracing=True`. You can also set: + ```bash + export CREWAI_TRACING_ENABLED=true + ``` + +2. **Log in to CrewAI** (required for traces to be sent and viewable): + ```bash + crewai login + ``` + Use a free account at [app.crewai.com](https://app.crewai.com). + +3. **Run the workflow** + After `crew.kickoff()` finishes, the run is sent to CrewAI AMP. You get: + - **Trace ID (session ID)** – printed in the green “Trace batch finalized” panel by CrewAI, and also in our summary. + - **Trace URL** – we put it in the result artifacts and print it at the end, e.g.: + ``` + Trace ID: + View trace: https://app.crewai.com/crewai_plus/trace_batches/ + ``` + +4. **View in the dashboard** + Open the URL above, or go to [app.crewai.com](https://app.crewai.com) → Traces and select the run. You’ll see agent decisions, task order, LLM calls, and token usage. + +If you don’t run `crewai login`, tracing still runs locally but no TraceID or URL is shown (the batch isn’t sent to the backend). + +## Skills + +Skills live under **`plugins/oape/skills//SKILL.md`**. Each `SKILL.md` is loaded and appended to a shared “skills context” that is injected into task descriptions. Agents are instructed to “apply the following skills and conventions where relevant.” + +**Adding a new skill:** + +1. Create a directory under `plugins/oape/skills/`, e.g. `plugins/oape/skills/api-conventions/`. +2. Add `SKILL.md` with clear sections (Purpose, When This Skill Applies, Guidelines, References). +3. Re-run the workflow; the new skill is picked up automatically. + +No code changes are required in the CrewAI setup when you add a skill. + +## Optional: Vertex AI + +To use Claude on Vertex instead of OpenAI: + +```bash +export OAPE_CREWAI_USE_VERTEX=1 +export ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project +export CLOUD_ML_REGION=us-east5 +export VERTEX_CLAUDE_MODEL=claude-3-5-haiku@20241022 +# authenticate +gcloud auth application-default login +python main.py ... +``` + +## Layout + +| File / dir | Purpose | +|------------|--------| +| `adapters/` | Adapter layer: `base.py` (WorkflowAdapter, WorkflowResult), `crewai_adapter.py`, `claude_sdk_adapter.py`, `factory.py` (get_adapter). | +| `skills_loader.py` | Loads all `SKILL.md` from `plugins/oape/skills/` and returns a single context string. | +| `context.py` | Project-agnostic scope (project name, repo URL, scope description, extra context). | +| `personas.py` | Generic SSE, PSE, SQE, Technical Writer personas. | +| `agents.py` | CrewAI agents (optionally with Vertex LLM). | +| `tasks.py` | Builds the 9 tasks with scope and skills context injected. | +| `main.py` | Entry point: parses scope and `--backend`, runs `get_adapter(backend).run(scope)`. | +| `llm_vertex.py` | Optional Vertex Claude LLM for CrewAI. | + +## Relation to OAPE commands + +The existing OAPE slash commands (`/oape:api-generate`, `/oape:api-implement`, `/oape:review`, etc.) are single-command, single-agent runs that generate or review **code** in the repo. This CrewAI workflow is **document-focused** (design doc, test plan, implementation outline, customer doc) and **multi-agent** (four roles, nine tasks). It reuses the same **skills** so that conventions (e.g. Effective Go) apply consistently whether you use the slash commands or the CrewAI pipeline. diff --git a/crewai/adapters/__init__.py b/crewai/adapters/__init__.py new file mode 100644 index 0000000..9f002d9 --- /dev/null +++ b/crewai/adapters/__init__.py @@ -0,0 +1,14 @@ +""" +Workflow adapters: switch between CrewAI and Claude SDK backends. + +Use the same scope (ProjectScope) and get a WorkflowResult from either: +- crewai: full 9-task document workflow (design -> review -> ... -> customer doc) +- claude-sdk: Claude Agent SDK path (e.g. api-implement via server or slash commands) + +Set OAPE_BACKEND=crewai | claude-sdk or pass --backend to main.py. +""" + +from .base import WorkflowAdapter, WorkflowResult +from .factory import get_adapter + +__all__ = ["WorkflowAdapter", "WorkflowResult", "get_adapter"] diff --git a/crewai/adapters/base.py b/crewai/adapters/base.py new file mode 100644 index 0000000..d558f78 --- /dev/null +++ b/crewai/adapters/base.py @@ -0,0 +1,60 @@ +""" +Common interface for workflow backends (CrewAI vs Claude SDK). + +Both adapters accept the same ProjectScope and return a WorkflowResult, +so callers can switch backends without changing code. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from context import ProjectScope + + +@dataclass +class WorkflowResult: + """Result from running the workflow with either backend.""" + + success: bool + """True if the workflow completed without fatal error.""" + + output_text: str + """Primary output (e.g. customer doc for CrewAI, implementation output for Claude SDK).""" + + backend: str + """Which backend ran: 'crewai' or 'claude-sdk'.""" + + artifacts: Optional[Dict[str, Any]] = None + """Backend-specific outputs (e.g. task outputs, cost, review report).""" + + error: Optional[str] = None + """If success is False, optional error message.""" + + def __post_init__(self) -> None: + if self.artifacts is None: + self.artifacts = {} + + +class WorkflowAdapter(ABC): + """Adapter interface: run the feature workflow with the given scope.""" + + @property + @abstractmethod + def backend_name(self) -> str: + """Identifier for this backend (e.g. 'crewai', 'claude-sdk').""" + pass + + @abstractmethod + def run(self, scope: "ProjectScope") -> WorkflowResult: + """ + Execute the workflow for the given scope. + + CrewAI: full 9-task pipeline (design -> design review -> ... -> customer doc). + Claude SDK: implementation (and optionally review) from EP; may require + scope.ep_url and operator repo path (env). + """ + pass diff --git a/crewai/adapters/claude_sdk_adapter.py b/crewai/adapters/claude_sdk_adapter.py new file mode 100644 index 0000000..5cd2304 --- /dev/null +++ b/crewai/adapters/claude_sdk_adapter.py @@ -0,0 +1,113 @@ +""" +Claude SDK backend: uses the OAPE server (Claude Agent SDK) for implementation from an EP. + +Requires the server to be running (or a server URL). Typically used for: +- api-implement: generate controller/reconciler code from an enhancement proposal PR. + +Scope should include an EP URL (via extra_context, or set OAPE_EP_URL / --ep-url). +Operator repo path: OAPE_OPERATOR_CWD or server default (cwd). +""" + +import json +import os +import re +from typing import Optional +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from .base import WorkflowAdapter, WorkflowResult + +EP_URL_PATTERN = re.compile( + r"https://github\.com/openshift/enhancements/pull/(\d+)", + re.IGNORECASE, +) + + +def _extract_ep_url_from_scope(scope) -> Optional[str]: + """Get EP URL from scope.extra_context or OAPE_EP_URL env.""" + ep_url = os.getenv("OAPE_EP_URL", "").strip() + if ep_url: + return ep_url + extra = (scope.extra_context or "") or "" + match = EP_URL_PATTERN.search(extra) + if match: + return f"https://github.com/openshift/enhancements/pull/{match.group(1)}" + return None + + +class ClaudeSDKAdapter(WorkflowAdapter): + """Execute the implementation workflow via the OAPE Claude SDK server (api-implement).""" + + def __init__(self, server_url: Optional[str] = None): + """ + server_url: base URL of the OAPE server (e.g. http://localhost:8000). + Defaults to OAPE_CLAUDE_SDK_SERVER_URL env. + """ + self.server_url = (server_url or os.getenv("OAPE_CLAUDE_SDK_SERVER_URL", "").strip()) or "http://localhost:8000" + + @property + def backend_name(self) -> str: + return "claude-sdk" + + def run(self, scope) -> WorkflowResult: + from context import ProjectScope + + if not isinstance(scope, ProjectScope): + return WorkflowResult( + success=False, + output_text="", + backend=self.backend_name, + error="scope must be a ProjectScope instance", + ) + + ep_url = _extract_ep_url_from_scope(scope) + if not ep_url: + return WorkflowResult( + success=False, + output_text="", + backend=self.backend_name, + error="Claude SDK backend requires an EP URL. Set OAPE_EP_URL or --ep-url, or include the URL in scope/context file.", + ) + + cwd = os.getenv("OAPE_OPERATOR_CWD", "").strip() + try: + url = f"{self.server_url.rstrip('/')}/api-implement?{urlencode({'ep_url': ep_url, 'cwd': cwd})}" + with urlopen(Request(url), timeout=300) as resp: + data = json.loads(resp.read().decode()) + if data.get("status") != "success": + return WorkflowResult( + success=False, + output_text=data.get("output", ""), + backend=self.backend_name, + artifacts=data, + error=data.get("detail", "Server returned non-success"), + ) + return WorkflowResult( + success=True, + output_text=data.get("output", ""), + backend=self.backend_name, + artifacts={ + "ep_url": data.get("ep_url"), + "cwd": data.get("cwd"), + "cost_usd": data.get("cost_usd"), + }, + ) + except HTTPError as e: + try: + body = e.fp.read().decode() if e.fp else "" + except Exception: + body = "" + return WorkflowResult( + success=False, + output_text=body[:2000] if body else "", + backend=self.backend_name, + error=f"Server returned {e.code}: {e.reason}", + ) + except Exception as e: + return WorkflowResult( + success=False, + output_text="", + backend=self.backend_name, + error=str(e), + ) diff --git a/crewai/adapters/crewai_adapter.py b/crewai/adapters/crewai_adapter.py new file mode 100644 index 0000000..d4667c4 --- /dev/null +++ b/crewai/adapters/crewai_adapter.py @@ -0,0 +1,353 @@ +""" +CrewAI backend: runs the full 11-task workflow (design -> review -> ... -> customer doc). + +Trace and thinking: agents use reasoning=True; Crew uses tracing=True. +- CREWAI_DEBUG_LLM=1: print full LLM request/response in hooks. +- CREWAI_DEBUG_REASONING=1: print full agent reasoning plan (what the AI is thinking) before each task. +- OAPE_CONTEXT_MAX_CHARS_PER_TASK: truncate each task output when used as context (default 10000) to avoid "prompt is too long" (200k token limit). +""" + +import os + +from .base import WorkflowAdapter, WorkflowResult + + +def _install_context_truncation(): + """Truncate task outputs when building context so the prompt stays under the model's token limit (e.g. 200k).""" + try: + max_chars = 5000 # per task; 11 * 5k = 55k chars keeps context well under 200k tokens + env_val = os.getenv("OAPE_CONTEXT_MAX_CHARS_PER_TASK", "").strip() + if env_val: + max_chars = max(1000, int(env_val)) + except ValueError: + max_chars = 5000 + from crewai.utilities import formatter as formatter_module + + _dividers = formatter_module.DIVIDERS + + def _truncated_aggregate(task_outputs): + parts = [] + for output in task_outputs: + raw = (output.raw or "") + if len(raw) > max_chars: + raw = raw[:max_chars] + "\n\n[... output truncated for context window; full output is in artifacts ...]" + parts.append(raw) + return _dividers.join(parts) + + formatter_module.aggregate_raw_outputs_from_task_outputs = _truncated_aggregate + + +# Apply context truncation when adapter is loaded so "from crewai import Crew" sees the patched formatter +_install_context_truncation() + +# Captured trace ID/URL/access_code (before and after CrewAI finalize_batch) +_last_trace_id: str | None = None +_last_trace_url: str | None = None +_last_trace_access_code: str | None = None + + +def _print_trace_now(): + """Print trace in same format as CrewAI panel / ZTWIM POC: one clear block per workflow.""" + global _last_trace_id, _last_trace_url, _last_trace_access_code + if not _last_trace_id and not _last_trace_url: + return + # Match CrewAI panel format (Trace Batch Finalization) so it matches the previous POC + message_parts = [ + f"✅ Trace batch finalized with session ID: {_last_trace_id or '(id)'}", + "", + f"🔗 View here: {_last_trace_url or ''}", + ] + if _last_trace_access_code: + message_parts.append(f"🔑 Access Code: {_last_trace_access_code}") + print("\n" + "\n".join(message_parts), flush=True) + + +def _parse_trace_url(url: str) -> tuple[str | None, str | None]: + """Extract trace_id and access_code from CrewAI trace URL if possible.""" + import re + if not url: + return None, None + # .../trace_batches/ or .../ephemeral_trace_batches/?access_code=... + m = re.search(r"/trace_batches/([^/?]+)|/ephemeral_trace_batches/([^/?]+)", url) + tid = (m.group(1) or m.group(2)) if m else None + m_ac = re.search(r"[?&]access_code=([^&]+)", url) + ac = m_ac.group(1) if m_ac else None + return tid, ac + + +def _crewai_trace_capture_hook(): + """Register for CrewKickoffCompletedEvent to capture trace ID and URL (before finalize clears them).""" + global _last_trace_id, _last_trace_url, _last_trace_access_code + _last_trace_id = None + _last_trace_url = None + _last_trace_access_code = None + + from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL + from crewai.events.event_bus import crewai_event_bus + from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener + from crewai.events.types.crew_events import CrewKickoffCompletedEvent + + @crewai_event_bus.on(CrewKickoffCompletedEvent) + def _capture_trace_before_finalize(_source, _event): + global _last_trace_id, _last_trace_url, _last_trace_access_code + try: + listener = TraceCollectionListener() + mgr = getattr(listener, "batch_manager", None) + if not mgr: + return + tid = getattr(mgr, "trace_batch_id", None) + if not tid and getattr(mgr, "current_batch", None): + tid = getattr(mgr.current_batch, "batch_id", None) + if tid: + _last_trace_id = tid + base = getattr(mgr, "plus_api", None) + base_url = getattr(base, "base_url", None) if base else None + base_url = base_url or DEFAULT_CREWAI_ENTERPRISE_URL + ephemeral_url = getattr(mgr, "ephemeral_trace_url", None) + if ephemeral_url: + _last_trace_url = ephemeral_url + _tid2, ac = _parse_trace_url(ephemeral_url) + if ac: + _last_trace_access_code = ac + else: + _last_trace_url = f"{base_url}/crewai_plus/trace_batches/{tid}" + except Exception: + pass + + +def _crewai_trace_capture_after_finalize(crew): + """Register handlers that run AFTER the trace listener: print trace at start (so user can watch live) and capture final URL/access_code at end.""" + global _last_trace_id, _last_trace_url, _last_trace_access_code + + from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL + from crewai.events.event_bus import crewai_event_bus + from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener + from crewai.events.types.crew_events import CrewKickoffCompletedEvent, CrewKickoffStartedEvent + + @crewai_event_bus.on(CrewKickoffStartedEvent) + def _print_trace_as_soon_as_ready(_source, _event): + """As soon as the run starts and the batch is initialized, print trace so user can open the dashboard and watch live.""" + global _last_trace_id, _last_trace_url + try: + listener = TraceCollectionListener() + mgr = getattr(listener, "batch_manager", None) + if not mgr: + return + if getattr(mgr, "wait_for_batch_initialization", None): + mgr.wait_for_batch_initialization(timeout=3.0) + tid = getattr(mgr, "trace_batch_id", None) + if not tid and getattr(mgr, "current_batch", None): + tid = getattr(mgr.current_batch, "batch_id", None) + if tid: + _last_trace_id = tid + base = getattr(mgr, "plus_api", None) + base_url = getattr(base, "base_url", None) if base else None + base_url = base_url or DEFAULT_CREWAI_ENTERPRISE_URL + _last_trace_url = f"{base_url}/crewai_plus/trace_batches/{tid}" + _print_trace_now() + except Exception: + pass + + @crewai_event_bus.on(CrewKickoffCompletedEvent) + def _capture_trace_after_finalize(_source, _event): + global _last_trace_id, _last_trace_url, _last_trace_access_code + try: + listener = TraceCollectionListener() + mgr = getattr(listener, "batch_manager", None) + if not mgr: + return + ephemeral_url = getattr(mgr, "ephemeral_trace_url", None) + if ephemeral_url: + _last_trace_url = ephemeral_url + tid, ac = _parse_trace_url(ephemeral_url) + if tid: + _last_trace_id = tid + if ac: + _last_trace_access_code = ac + _print_trace_now() + except Exception: + pass + + +def _crewai_reasoning_hooks(): + """Register event handlers to print full agent reasoning when CREWAI_DEBUG_REASONING=1.""" + if os.getenv("CREWAI_DEBUG_REASONING", "").strip().lower() not in ("1", "true", "yes"): + return + from crewai.events.event_bus import crewai_event_bus + from crewai.events.types.reasoning_events import ( + AgentReasoningCompletedEvent, + AgentReasoningStartedEvent, + ) + + @crewai_event_bus.on(AgentReasoningStartedEvent) + def _on_reasoning_started(_source, event): + print("\n" + "=" * 60) + print(f"[REASONING] Agent: {event.agent_role} | Task ID: {event.task_id} | Attempt: {event.attempt}") + print("Thinking...") + print("=" * 60) + + @crewai_event_bus.on(AgentReasoningCompletedEvent) + def _on_reasoning_completed(_source, event): + print("\n" + "=" * 60) + print(f"[REASONING] Agent: {event.agent_role} | Task ID: {event.task_id}") + print(f"Ready to execute: {event.ready}") + print("-" * 60) + print("Full plan (what the AI is thinking):") + print(event.plan or "(empty)") + print("=" * 60 + "\n") + + +def _crewai_llm_hooks(): + """Register CrewAI before/after LLM hooks. Log each task once, then short lines for later calls.""" + from crewai.hooks import after_llm_call, before_llm_call + + _logged_task_keys = set() + + @before_llm_call + def _log_request(context): + task_id = getattr(context.task, "id", None) or id(context.task) + key = (task_id, context.agent.role) + if key not in _logged_task_keys: + _logged_task_keys.add(key) + task_desc = (context.task.description or "")[:80].replace("\n", " ") + print(f"\n[LLM] → Agent: {context.agent.role} | Task: {task_desc}...") + else: + print(f"\n[LLM] → Agent: {context.agent.role} | (same task) iter {context.iterations} msgs {len(context.messages)}") + return None + + @after_llm_call + def _log_response(context): + if context.response is None: + return None + n = len(context.response) + if os.getenv("CREWAI_DEBUG_LLM", "").strip().lower() in ("1", "true", "yes"): + print(f"\n[LLM] ← Response ({n} chars):\n{context.response}\n") + else: + preview = (context.response[:300] + "...") if n > 300 else context.response + print(f"\n[LLM] ← Response ({n} chars): {preview}") + return None + + +class CrewAIAdapter(WorkflowAdapter): + """Execute the OAPE workflow using CrewAI (4 agents, 9 sequential tasks).""" + + @property + def backend_name(self) -> str: + return "crewai" + + def run(self, scope) -> WorkflowResult: + _crewai_llm_hooks() + _crewai_reasoning_hooks() + _crewai_trace_capture_hook() # register before Crew so we capture trace ID before finalize clears it + from crewai import Crew, Process + from agents import build_agents + from context import ProjectScope + from tasks import build_tasks + + if not isinstance(scope, ProjectScope): + return WorkflowResult( + success=False, + output_text="", + backend=self.backend_name, + error="scope must be a ProjectScope instance", + ) + try: + # When repo_layout is set, put project tree in agent backstory (once per agent) and exclude from task scope + agents_list = build_agents(repo_layout=scope.repo_layout) + tasks = build_tasks( + scope, + agents=agents_list, + scope_include_repo_layout=False if scope.repo_layout else True, + ) + crew = Crew( + agents=agents_list, + tasks=tasks, + process=Process.sequential, + verbose=True, + tracing=True, + ) + _crewai_trace_capture_after_finalize(crew) # run after trace listener to capture URL + access_code + result = crew.kickoff() + # Wait for event handlers to finish so trace ID/URL are captured (handlers run in thread pool) + try: + from crewai.events.event_bus import crewai_event_bus + crewai_event_bus.flush(timeout=30.0) + except Exception: + pass + output_text = str(result) if result is not None else "" + artifacts = {} + # Token usage and cost (CrewOutput has token_usage after kickoff) + usage = getattr(result, "token_usage", None) if result is not None else None + if usage is None: + usage = getattr(crew, "token_usage", None) + if usage is not None: + artifacts["token_usage"] = { + "prompt_tokens": getattr(usage, "prompt_tokens", 0), + "completion_tokens": getattr(usage, "completion_tokens", 0), + "total_tokens": getattr(usage, "total_tokens", 0), + "successful_requests": getattr(usage, "successful_requests", 0), + } + try: + from cost_estimator import estimate_cost_from_usage_metrics + cost_info = estimate_cost_from_usage_metrics(usage) + if cost_info: + artifacts["estimated_cost_usd"] = cost_info["cost_usd"] + artifacts["cost_estimate"] = cost_info + except Exception: + pass + for i, task in enumerate(tasks, start=1): + out = getattr(task, "output", None) + if out is not None and hasattr(out, "raw"): + raw = out.raw or "" + # Keep full output for code tasks (5=unit tests, 6=implementation); cap others for size + max_len = 80_000 if i in (5, 6) else 2000 + artifacts[f"task_{i}"] = raw[:max_len] if len(raw) > max_len else raw + # Use trace ID/URL/access_code from event handlers; fallback: read from listener once more after kickoff + if _last_trace_id: + artifacts["trace_id"] = _last_trace_id + if _last_trace_url: + artifacts["trace_url"] = _last_trace_url + if _last_trace_access_code: + artifacts["trace_access_code"] = _last_trace_access_code + # Fallback: read from TraceBatchManager (ephemeral_trace_url is left set after finalize_batch) + if not artifacts.get("trace_id") or not artifacts.get("trace_url"): + try: + from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL + from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener + listener = TraceCollectionListener() + mgr = getattr(listener, "batch_manager", None) + if mgr: + url = getattr(mgr, "ephemeral_trace_url", None) + if url: + if not artifacts.get("trace_url"): + artifacts["trace_url"] = url + tid, ac = _parse_trace_url(url) + if tid and not artifacts.get("trace_id"): + artifacts["trace_id"] = tid + if ac and not artifacts.get("trace_access_code"): + artifacts["trace_access_code"] = ac + if not artifacts.get("trace_id") or not artifacts.get("trace_url"): + tid = getattr(mgr, "trace_batch_id", None) + if tid: + if not artifacts.get("trace_id"): + artifacts["trace_id"] = tid + if not artifacts.get("trace_url"): + base = getattr(mgr, "plus_api", None) + base_url = getattr(base, "base_url", None) if base else None + base_url = base_url or DEFAULT_CREWAI_ENTERPRISE_URL + artifacts["trace_url"] = f"{base_url}/crewai_plus/trace_batches/{tid}" + except Exception: + pass + return WorkflowResult( + success=True, + output_text=output_text, + backend=self.backend_name, + artifacts=artifacts, + ) + except Exception as e: + return WorkflowResult( + success=False, + output_text="", + backend=self.backend_name, + error=str(e), + ) diff --git a/crewai/adapters/factory.py b/crewai/adapters/factory.py new file mode 100644 index 0000000..36695dd --- /dev/null +++ b/crewai/adapters/factory.py @@ -0,0 +1,22 @@ +""" +Factory: return the workflow adapter for the selected backend (crewai | claude-sdk). +""" + +import os +from typing import Optional + +from .base import WorkflowAdapter +from .crewai_adapter import CrewAIAdapter +from .claude_sdk_adapter import ClaudeSDKAdapter + + +def get_adapter(backend: Optional[str] = None) -> WorkflowAdapter: + """ + Return the adapter for the given backend. + + backend: "crewai" | "claude-sdk". Defaults to OAPE_BACKEND env, then "crewai". + """ + name = (backend or os.getenv("OAPE_BACKEND", "crewai")).strip().lower() + if name in ("claude-sdk", "claude_sdk", "claudesdk"): + return ClaudeSDKAdapter() + return CrewAIAdapter() diff --git a/crewai/agents.py b/crewai/agents.py new file mode 100644 index 0000000..b6f44b3 --- /dev/null +++ b/crewai/agents.py @@ -0,0 +1,105 @@ +""" +CrewAI agents for the OAPE workflow (project-agnostic). + +Skills and repository layout are injected once per agent (in backstory) so they are not +repeated in every task description, reducing noise and token use. +""" + +import os +from typing import List, Optional + +from crewai import Agent + +from personas import ( + SSE_PERSONA, + PSE_PERSONA, + SQE_PERSONA, + TECHNICAL_WRITER_PERSONA, +) +from skills_loader import get_skills_context_for_agents + + +def _get_llm(): + """Use Vertex Claude (if configured) or CrewAI default (OpenAI). Fails with a clear error if neither is set.""" + use_vertex = os.getenv("OAPE_CREWAI_USE_VERTEX", "").strip().lower() in ("1", "true", "yes") + has_vertex_vars = bool( + os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip() + and os.getenv("CLOUD_ML_REGION", "").strip() + ) + has_openai = bool(os.getenv("OPENAI_API_KEY", "").strip()) + + # Prefer Vertex when explicitly requested or when Vertex vars are set and OpenAI is not + if use_vertex or (has_vertex_vars and not has_openai): + try: + from llm_vertex import VertexClaudeLLM + return VertexClaudeLLM( + model=os.getenv("VERTEX_CLAUDE_MODEL", "claude-3-5-haiku@20241022"), + project_id=os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", ""), + region=os.getenv("CLOUD_ML_REGION", "us-east5"), + temperature=0.2, + max_tokens=8192, + ) + except Exception as e: + if use_vertex or not has_openai: + raise RuntimeError( + "Vertex LLM failed (OAPE_CREWAI_USE_VERTEX or no OPENAI_API_KEY). " + "Set ANTHROPIC_VERTEX_PROJECT_ID, CLOUD_ML_REGION, and run 'gcloud auth application-default login'. " + f"Detail: {e}" + ) from e + if has_openai: + return None # CrewAI will use default (OpenAI) + raise ValueError( + "No LLM configured. Set one of:\n" + " • OPENAI_API_KEY for OpenAI, or\n" + " • OAPE_CREWAI_USE_VERTEX=1 with ANTHROPIC_VERTEX_PROJECT_ID and CLOUD_ML_REGION (and 'gcloud auth application-default login') for Vertex Claude." + ) + + +def _agent( + persona: dict, + allow_delegation: bool = False, + repo_layout: Optional[str] = None, +) -> Agent: + llm = _get_llm() + max_reasoning = 20 + try: + env_val = os.getenv("OAPE_MAX_REASONING_ATTEMPTS", "").strip() + if env_val: + max_reasoning = max(1, int(env_val)) + except ValueError: + pass + backstory = persona["backstory"] + skills_for_agents = get_skills_context_for_agents() + if skills_for_agents: + backstory = backstory.rstrip() + skills_for_agents + if repo_layout: + from context import get_repo_layout_for_backstory + layout_block = get_repo_layout_for_backstory(repo_layout) + if layout_block: + backstory = backstory.rstrip() + layout_block + kwargs = { + "role": persona["role"], + "goal": persona["goal"], + "backstory": backstory, + "allow_delegation": allow_delegation, + "verbose": True, + "reasoning": True, # show agent plan/thinking before executing each task (for debugging) + "max_reasoning_attempts": max_reasoning, + } + if llm is not None: + kwargs["llm"] = llm + return Agent(**kwargs) + + +def build_agents(repo_layout: Optional[str] = None) -> List[Agent]: + """Build the four workflow agents. When repo_layout is provided, it is added to each agent's backstory (project tree once per agent, not in every task).""" + return [ + _agent(SSE_PERSONA, allow_delegation=True, repo_layout=repo_layout), + _agent(PSE_PERSONA, repo_layout=repo_layout), + _agent(SQE_PERSONA, repo_layout=repo_layout), + _agent(TECHNICAL_WRITER_PERSONA, repo_layout=repo_layout), + ] + + +# Module-level agents (no repo layout in backstory; used when adapter does not pass scope layout) +sse, pse, sqe, technical_writer = build_agents(None) diff --git a/crewai/command_prompts_loader.py b/crewai/command_prompts_loader.py new file mode 100644 index 0000000..8b135b2 --- /dev/null +++ b/crewai/command_prompts_loader.py @@ -0,0 +1,121 @@ +""" +Load prompting content from plugins/oape/commands/*.md for reuse in CrewAI tasks. + +The /oape slash commands (api-generate, api-implement, api-generate-tests, review, +implement-review-fixes) contain the authoritative instructions for feature development. +This loader extracts the Description and optional Implementation criteria so CrewAI +tasks can reuse the same prompting instead of duplicating it. +""" + +import re +from pathlib import Path +from typing import Dict, Optional + + +def _find_commands_dir() -> Path: + """Resolve plugins/oape/commands relative to this file (crewai/command_prompts_loader.py).""" + crewai_dir = Path(__file__).resolve().parent + repo_root = crewai_dir.parent + return repo_root / "plugins" / "oape" / "commands" + + +def _extract_section(content: str, section_header: str) -> Optional[str]: + """Extract one section from markdown (from ## Section until next ## or end).""" + pattern = rf"^##\s+{re.escape(section_header)}\s*$" + match = re.search(pattern, content, re.IGNORECASE | re.MULTILINE) + if not match: + return None + start = match.end() + next_section = re.search(r"\n##\s+", content[start:]) + end = start + next_section.start() if next_section else len(content) + return content[start:end].strip() + + +def _extract_review_criteria(content: str) -> Optional[str]: + """Extract review criteria from review.md (Step 3 and modules, without bash blocks).""" + impl = _extract_section(content, "Implementation") + if not impl: + return None + # Find "### Step 3: Analyze" through "### Step 4" or end + step3 = re.search(r"###\s+Step\s+3:.*?(?=###\s+Step\s+4|$)", impl, re.DOTALL | re.IGNORECASE) + if not step3: + return None + text = step3.group(0).strip() + # Remove bash code blocks so we keep only the criteria text + text = re.sub(r"```\w*\n.*?```", "", text, flags=re.DOTALL) + return text.strip() or None + + +def load_command_prompts() -> Dict[str, str]: + """ + Load the Description section from each plugins/oape/commands/*.md. + + Returns: + Dict mapping command stem (e.g. 'api-implement', 'review') to the + Description section text. Used to inject into CrewAI task descriptions. + """ + commands_dir = _find_commands_dir() + if not commands_dir.is_dir(): + return {} + + result: Dict[str, str] = {} + for md_file in sorted(commands_dir.glob("*.md")): + stem = md_file.stem + try: + content = md_file.read_text(encoding="utf-8") + except Exception: + continue + desc = _extract_section(content, "Description") + if desc: + result[stem] = desc + return result + + +def get_prompt_for_task(task_kind: str) -> str: + """ + Get the combined prompt excerpt(s) relevant for a CrewAI task kind. + + task_kind: one of 'design' | 'design_review' | 'test_cases' | 'implementation_outline' + | 'quality' | 'code_review' | 'address_review' | 'writeup' | 'customer_doc' + + Returns a string to inject into the task description (may be empty if no mapping). + """ + prompts = load_command_prompts() + if not prompts: + return "" + + parts = [] + if task_kind == "design": + # Design should align with what api-generate and api-implement expect (API + reconciliation intent) + if "api-generate" in prompts: + parts.append("**API/types (align with /oape:api-generate):**\n" + prompts["api-generate"]) + if "api-implement" in prompts: + parts.append("**Implementation intent (align with /oape:api-implement):**\n" + prompts["api-implement"]) + elif task_kind == "design_review": + if "review" in prompts: + parts.append("**Review standards (align with /oape:review):**\n" + prompts["review"]) + elif task_kind == "test_cases": + if "api-generate-tests" in prompts: + parts.append("**Test expectations (align with /oape:api-generate-tests):**\n" + prompts["api-generate-tests"]) + elif task_kind == "implementation_outline": + if "api-implement" in prompts: + parts.append("**Implementation requirements (align with /oape:api-implement):**\n" + prompts["api-implement"]) + elif task_kind == "code_review": + if "review" in prompts: + parts.append("**Review criteria (align with /oape:review):**\n" + prompts["review"]) + elif task_kind == "address_review": + if "implement-review-fixes" in prompts: + parts.append("**Resolution standards (align with /oape:implement-review-fixes):**\n" + prompts["implement-review-fixes"]) + + if not parts: + return "" + return "\n\n".join(parts) + + +def get_all_descriptions_for_context() -> str: + """Single blob of all command descriptions for injection as shared context.""" + prompts = load_command_prompts() + if not prompts: + return "" + parts = [f"### /oape:{name}\n{text}" for name, text in sorted(prompts.items())] + return "Apply the following OAPE command requirements where relevant:\n\n" + "\n\n".join(parts) diff --git a/crewai/context.py b/crewai/context.py new file mode 100644 index 0000000..277612c --- /dev/null +++ b/crewai/context.py @@ -0,0 +1,230 @@ +""" +Project-agnostic scope and context for the CrewAI workflow. + +Scope can be set via: +- CLI / env: project name, repo URL, scope description +- Context file: a .txt file (optional PROJECT_NAME=, REPO_URL= headers, then body) +- GitHub EP URL: fetch enhancement proposal content from openshift/enhancements PR +""" + +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + + +def _scope_limit(env_name: str, default: int) -> int: + try: + v = os.getenv(env_name, "").strip() + return max(1000, int(v)) if v else default + except ValueError: + return default + + +@dataclass +class ProjectScope: + """Scope for a single CrewAI run: what project and what feature/area.""" + + project_name: str + repo_url: str + scope_description: str + extra_context: Optional[str] = None + repo_path: Optional[str] = None + """Optional path to a local clone; when set, repo_layout is used to constrain paths.""" + repo_layout: Optional[str] = None + """Directory tree of repo_path so agents suggest only existing paths.""" + + def to_markdown( + self, + max_scope_chars: Optional[int] = None, + max_repo_layout_chars: Optional[int] = None, + max_extra_context_chars: int = 4000, + include_repo_layout: bool = True, + ) -> str: + """Format scope for injection into task descriptions. Truncated to stay under token limits. + Set include_repo_layout=False when repo layout is injected in agent backstory instead.""" + max_scope_chars = max_scope_chars or _scope_limit("OAPE_SCOPE_MAX_CHARS", 12000) + max_repo_layout_chars = max_repo_layout_chars or _scope_limit("OAPE_REPO_LAYOUT_MAX_CHARS", 6000) + max_extra = _scope_limit("OAPE_EXTRA_CONTEXT_MAX_CHARS", 4000) + scope = (self.scope_description or "")[:max_scope_chars] + if len(self.scope_description or "") > max_scope_chars: + scope += "\n\n[... scope truncated for context window ...]" + lines = [ + f"**Project:** {self.project_name}", + f"**Repository:** {self.repo_url}", + "", + "**Scope for this run:**", + scope, + ] + extra = (self.extra_context or "")[:max_extra] + if extra: + lines.extend(["", "**Additional context:**", extra]) + if include_repo_layout and self.repo_layout: + layout = self.repo_layout[:max_repo_layout_chars] + if len(self.repo_layout) > max_repo_layout_chars: + layout += "\n... [layout truncated]" + lines.extend([ + "", + "**Current repository layout (you MUST suggest only files/packages under this structure):**", + "```", + layout, + "```", + ]) + return "\n".join(lines) + + +def get_repo_layout_for_backstory(repo_layout: Optional[str], max_chars: Optional[int] = None) -> str: + """Format repository layout for injection into agent backstory (once per agent). Returns empty string if none.""" + if not repo_layout or not repo_layout.strip(): + return "" + limit = max_chars or _scope_limit("OAPE_REPO_LAYOUT_MAX_CHARS", 6000) + layout = repo_layout.strip()[:limit] + if len(repo_layout.strip()) > limit: + layout += "\n... [repository layout truncated]" + return "\n\n**Repository layout (use ONLY these paths or new files under existing packages):**\n```\n" + layout + "\n```" + + +def get_repo_layout(repo_path: str, max_entries: int = 400, max_depth: int = 6) -> Optional[str]: + """ + Return a directory/file tree for the given path so agents can suggest only existing paths. + + Skips .git, vendor, node_modules, __pycache__, .venv. Returns None if path is not a directory. + """ + root = Path(repo_path).resolve() + if not root.is_dir(): + return None + skip_dirs = {".git", "vendor", "node_modules", "__pycache__", ".venv", "venv", "_output"} + lines = [] + count = 0 + + def walk(dir_path: Path, prefix: str, depth: int) -> None: + nonlocal count + if depth > max_depth or count >= max_entries: + return + try: + entries = sorted(dir_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())) + except OSError: + return + for i, entry in enumerate(entries): + if count >= max_entries: + return + if entry.name.startswith(".") and entry.name != ".": + continue + if entry.is_dir() and entry.name in skip_dirs: + continue + is_last = i == len(entries) - 1 + branch = "└── " if is_last else "├── " + lines.append(prefix + branch + entry.name) + count += 1 + if entry.is_dir(): + ext = " " if is_last else "│ " + walk(entry, prefix + ext, depth + 1) + + walk(root, "", 0) + return "\n".join(lines) if lines else None + + +def default_scope() -> ProjectScope: + """Example scope when none is provided (e.g. for testing).""" + return ProjectScope( + project_name="OpenShift Operator", + repo_url="https://github.com/openshift/example-operator", + scope_description=( + "Add a new API and controller for a custom resource. " + "Follow controller-runtime and OpenShift API conventions." + ), + ) + + +# --- Context from text file --- + +def load_context_from_file(file_path: str) -> Tuple[Optional[str], Optional[str], str]: + """ + Load scope from a text file. + + Optional format: lines "PROJECT_NAME=...", "REPO_URL=..." at the start, + then a blank line or "---", then the rest is the scope description. + If no key=value lines, the entire file content is the scope description. + + Returns: + (project_name or None, repo_url or None, scope_description) + """ + path = Path(file_path) + if not path.is_file(): + return None, None, "" + + text = path.read_text(encoding="utf-8").strip() + if not text: + return None, None, "" + + project_name = None + repo_url = None + lines = text.split("\n") + body_start = 0 + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.upper().startswith("PROJECT_NAME="): + project_name = stripped.split("=", 1)[1].strip() + body_start = i + 1 + elif stripped.upper().startswith("REPO_URL="): + repo_url = stripped.split("=", 1)[1].strip() + body_start = i + 1 + elif stripped == "---" or stripped == "": + body_start = i + 1 + break + else: + if project_name is not None or repo_url is not None: + body_start = i + break + scope_desc = "\n".join(lines[body_start:]).strip() + # If no key=value lines, entire file is the scope description + if project_name is None and repo_url is None and body_start == 0: + return None, None, text + # If key=value lines present but no body, leave scope_desc empty (do not use full file) + if not scope_desc and (project_name is not None or repo_url is not None): + return project_name, repo_url, "" + return project_name, repo_url, scope_desc or "" + + +# --- Context from GitHub EP (Enhancement Proposal) --- + +EP_URL_PATTERN = re.compile( + r"^https://github\.com/openshift/enhancements/pull/(\d+)/?$", + re.IGNORECASE, +) + + +def load_context_from_ep_url(ep_url: str) -> Optional[str]: + """ + Fetch Enhancement Proposal content from a GitHub PR URL. + + Expects: https://github.com/openshift/enhancements/pull/ + Uses `gh pr view --repo openshift/enhancements` if available, + otherwise returns None (caller can use the URL as fallback). + + Returns: + PR body text (title + body) or None if fetch fails. + """ + ep_url = ep_url.rstrip("/") + match = EP_URL_PATTERN.match(ep_url) + if not match: + return None + pr_number = match.group(1) + try: + out = subprocess.run( + ["gh", "pr", "view", pr_number, "--repo", "openshift/enhancements", "--json", "title,body"], + capture_output=True, + text=True, + timeout=30, + ) + if out.returncode != 0 or not out.stdout: + return None + import json + data = json.loads(out.stdout) + title = data.get("title") or "" + body = data.get("body") or "" + return f"# {title}\n\n{body}".strip() + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): + return None diff --git a/crewai/cost_estimator.py b/crewai/cost_estimator.py new file mode 100644 index 0000000..b023710 --- /dev/null +++ b/crewai/cost_estimator.py @@ -0,0 +1,107 @@ +""" +Estimate LLM cost from token usage (CrewAI UsageMetrics or prompt/completion counts). + +Pricing can be set via env for custom models: + OAPE_COST_INPUT_PER_1M - USD per 1M input tokens (default from model table or 0) + OAPE_COST_OUTPUT_PER_1M - USD per 1M output tokens + OAPE_MODEL_NAME - Model name for display (e.g. claude-3-5-haiku) +""" + +import os +from typing import Any, Optional + + +# Approximate USD per 1M tokens (input, output). Update as needed; override with env. +_DEFAULT_PRICES: dict[str, tuple[float, float]] = { + "claude-3-5-haiku": (0.80, 4.00), + "claude-3-5-sonnet": (3.00, 15.00), + "claude-3-opus": (15.00, 75.00), + "gpt-4o": (2.50, 10.00), + "gpt-4o-mini": (0.15, 0.60), + "gpt-4-turbo": (10.00, 30.00), +} + + +def estimate_cost( + prompt_tokens: int = 0, + completion_tokens: int = 0, + total_tokens: Optional[int] = None, + model_name: Optional[str] = None, +) -> dict[str, Any]: + """ + Estimate cost in USD from token counts. + + Args: + prompt_tokens: Input/prompt tokens. + completion_tokens: Output/completion tokens. + total_tokens: If set and prompt+completion are 0, used for rough estimate (split 70/30). + model_name: Model identifier for pricing lookup (e.g. claude-3-5-haiku). + + Returns: + Dict with prompt_tokens, completion_tokens, total_tokens, cost_usd, model (display). + """ + if total_tokens is not None and prompt_tokens == 0 and completion_tokens == 0: + prompt_tokens = int(total_tokens * 0.7) + completion_tokens = total_tokens - prompt_tokens + total = prompt_tokens + completion_tokens + + model = (model_name or os.getenv("OAPE_MODEL_NAME", "").strip()).lower() or None + input_per_1m = None + output_per_1m = None + try: + input_per_1m = float(os.getenv("OAPE_COST_INPUT_PER_1M", "").strip()) + except (ValueError, AttributeError): + pass + try: + output_per_1m = float(os.getenv("OAPE_COST_OUTPUT_PER_1M", "").strip()) + except (ValueError, AttributeError): + pass + + if input_per_1m is None or output_per_1m is None: + # Prefer longest matching key so "claude-3-5-sonnet" matches before "claude-3-5" if both existed + best_key = None + if model: + for key in sorted(_DEFAULT_PRICES.keys(), key=len, reverse=True): + if key in model: + best_key = key + break + if best_key is not None: + in_p, out_p = _DEFAULT_PRICES[best_key] + input_per_1m = input_per_1m if input_per_1m is not None else in_p + output_per_1m = output_per_1m if output_per_1m is not None else out_p + if input_per_1m is None: + input_per_1m = 1.0 # fallback guess + if output_per_1m is None: + output_per_1m = 4.0 + + cost_usd = (prompt_tokens / 1_000_000 * input_per_1m) + ( + completion_tokens / 1_000_000 * output_per_1m + ) + + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total, + "cost_usd": round(cost_usd, 4), + "model": model_name or model or "unknown", + } + + +def estimate_cost_from_usage_metrics(usage: Any) -> Optional[dict[str, Any]]: + """ + Build cost estimate from CrewAI UsageMetrics (or object with prompt_tokens, completion_tokens, total_tokens). + """ + if usage is None: + return None + prompt = getattr(usage, "prompt_tokens", None) or getattr(usage, "input_tokens", 0) + completion = getattr(usage, "completion_tokens", None) or getattr(usage, "output_tokens", 0) + total = getattr(usage, "total_tokens", None) + if isinstance(prompt, (int, float)) and isinstance(completion, (int, float)): + prompt, completion = int(prompt), int(completion) + else: + return None + return estimate_cost( + prompt_tokens=prompt, + completion_tokens=completion, + total_tokens=int(total) if total is not None else None, + ) diff --git a/crewai/example_scope.txt b/crewai/example_scope.txt new file mode 100644 index 0000000..c6e487f --- /dev/null +++ b/crewai/example_scope.txt @@ -0,0 +1,10 @@ +PROJECT_NAME=My OpenShift Operator +REPO_URL=https://github.com/openshift/my-operator +--- +Add a new Custom Resource and controller for the Foo resource. + +Requirements: +- New CRD with spec fields: name, replicas, image. +- Controller reconciles Foo and creates a Deployment and Service. +- Follow controller-runtime and OpenShift API conventions. +- Include validation (e.g. replicas > 0), finalizer for cleanup. diff --git a/crewai/llm_vertex.py b/crewai/llm_vertex.py new file mode 100644 index 0000000..ec12168 --- /dev/null +++ b/crewai/llm_vertex.py @@ -0,0 +1,77 @@ +""" +Optional: CrewAI LLM using Claude via Google Vertex AI (no API key). +Use when OAPE_CREWAI_USE_VERTEX=1. Requires gcloud auth application-default login +or GOOGLE_APPLICATION_CREDENTIALS. +""" + +from typing import Any, Dict, List, Optional, Union + +from crewai import BaseLLM + + +class VertexClaudeLLM(BaseLLM): + """Claude on Vertex AI via ADC.""" + + def __init__( + self, + model: str, + project_id: str, + region: str, + temperature: Optional[float] = None, + max_tokens: int = 8192, + ): + super().__init__(model=model, temperature=temperature or 0.2) + self._project_id = project_id + self._region = region + self._max_tokens = max_tokens + self._client = None + + @property + def client(self): + if self._client is None: + from anthropic import AnthropicVertex + self._client = AnthropicVertex( + project_id=self._project_id, + region=self._region, + ) + return self._client + + def call( + self, + messages: Union[str, List[Dict[str, str]]], + tools: Optional[List[dict]] = None, + callbacks: Optional[List[Any]] = None, + available_functions: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> Union[str, Any]: + if isinstance(messages, str): + messages = [{"role": "user", "content": messages}] + formatted = [] + for m in messages: + role = (m.get("role") or "user").lower() + if role == "human": + role = "user" + if role not in ("user", "assistant"): + role = "user" + content = m.get("content") or m.get("text") or "" + if isinstance(content, list): + content = next((c.get("text", "") for c in content if isinstance(c, dict)), str(content)) + formatted.append({"role": role, "content": str(content)}) + if not formatted: + return "" + response = self.client.messages.create( + model=self.model, + max_tokens=self._max_tokens, + messages=formatted, + temperature=self.temperature, + ) + if response.content and len(response.content) > 0: + block = response.content[0] + return getattr(block, "text", str(block)) if hasattr(block, "text") else str(block) + return "" + + def supports_function_calling(self) -> bool: + return True + + def get_context_window_size(self) -> int: + return 200_000 diff --git a/crewai/main.py b/crewai/main.py new file mode 100644 index 0000000..36df71e --- /dev/null +++ b/crewai/main.py @@ -0,0 +1,306 @@ +""" +Run the OAPE CrewAI workflow (project-agnostic). + +Context can be set via: +- Env: OAPE_PROJECT_NAME, OAPE_REPO_URL, OAPE_SCOPE_DESCRIPTION +- CLI: --project-name, --repo-url, --scope +- Context file: --context-file path/to/scope.txt (optional PROJECT_NAME=, REPO_URL=, then body) +- GitHub EP: --ep-url https://github.com/openshift/enhancements/pull/NNNN (fetches PR body) +Skills are loaded from plugins/oape/skills/*/SKILL.md automatically. + +To persist outputs in the repo after tasks complete, use --output-dir (or OAPE_OUTPUT_DIR). +Outputs written: customer_doc.md and task_1_design.md through task_9_customer_doc.md. +""" + +import argparse +import os +import sys +from pathlib import Path + +# Ensure crewai dir is on path so that context, agents, tasks, adapters resolve +_CREWAI_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _CREWAI_DIR.parent +if str(_CREWAI_DIR) not in sys.path: + sys.path.insert(0, str(_CREWAI_DIR)) + +from dotenv import load_dotenv + +from adapters import get_adapter +from context import ( + ProjectScope, + default_scope, + get_repo_layout, + load_context_from_file, + load_context_from_ep_url, +) + +# Load .env from oape-ai-e2e and from workspace root (multi-agent-orchestration) so CREWAI_TRACING_ENABLED etc. match the ZTWIM POC +load_dotenv(_REPO_ROOT / ".env") +_workspace_root = _REPO_ROOT.parent +load_dotenv(_workspace_root / ".env") +load_dotenv() + + +def _scope_from_env() -> ProjectScope: + name = os.getenv("OAPE_PROJECT_NAME", "").strip() + repo = os.getenv("OAPE_REPO_URL", "").strip() + desc = os.getenv("OAPE_SCOPE_DESCRIPTION", "").strip() + extra = os.getenv("OAPE_EXTRA_CONTEXT", "").strip() or None + if name and repo and desc: + return ProjectScope( + project_name=name, + repo_url=repo, + scope_description=desc, + extra_context=extra, + ) + return default_scope() + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run OAPE CrewAI workflow. Context from env, CLI, --context-file, or --ep-url." + ) + parser.add_argument("--project-name", type=str, help="Project name (or OAPE_PROJECT_NAME)") + parser.add_argument("--repo-url", type=str, help="Repository URL (or OAPE_REPO_URL)") + parser.add_argument("--scope", type=str, help="Scope description (or OAPE_SCOPE_DESCRIPTION)") + parser.add_argument("--extra", type=str, default=None, help="Extra context (or OAPE_EXTRA_CONTEXT)") + parser.add_argument( + "--context-file", + type=str, + metavar="PATH", + help="Load scope from a .txt file. Optional headers: PROJECT_NAME=..., REPO_URL=..., then --- or blank line, then body.", + ) + parser.add_argument( + "--ep-url", + type=str, + metavar="URL", + help="Load context from GitHub EP PR, e.g. https://github.com/openshift/enhancements/pull/1234 (requires gh CLI). Required for claude-sdk backend.", + ) + parser.add_argument( + "--backend", + type=str, + choices=["crewai", "claude-sdk"], + default=os.getenv("OAPE_BACKEND", "crewai"), + help="Backend to run: crewai (full 9-task doc workflow) or claude-sdk (server api-implement). Default: OAPE_BACKEND or crewai.", + ) + parser.add_argument( + "--repo-path", + type=str, + metavar="PATH", + help="Path to local repo clone; layout is injected so agents suggest only existing paths (or OAPE_REPO_PATH / OAPE_OPERATOR_CWD).", + ) + parser.add_argument( + "--output-dir", + type=str, + metavar="PATH", + help="Write workflow outputs to this directory (creates dir if needed). E.g. path inside repo to see changes: --output-dir /path/to/repo/docs/oape. Env: OAPE_OUTPUT_DIR.", + ) + parser.add_argument( + "--apply-to-repo", + action="store_true", + help="After workflow success: create a git branch in the repo, write generated code, verify compile, and commit. Default: True when --repo-path is set (crewai backend).", + ) + parser.add_argument( + "--no-apply-to-repo", + action="store_true", + help="Do not apply code to the repo even when --repo-path is set (overrides default).", + ) + parser.add_argument( + "--branch-name", + type=str, + metavar="NAME", + help="Git branch name for --apply-to-repo (default: oape/-).", + ) + args = parser.parse_args() + + # When --repo-path is set (crewai), apply code to that repo by default so code changes go to the same path given + if getattr(args, "no_apply_to_repo", False): + args.apply_to_repo = False + elif args.repo_path and args.backend == "crewai": + args.apply_to_repo = True + + # Env fallbacks for context file and EP URL + if not args.context_file and os.getenv("OAPE_CONTEXT_FILE", "").strip(): + args.context_file = os.getenv("OAPE_CONTEXT_FILE", "").strip() + if not args.ep_url and os.getenv("OAPE_EP_URL", "").strip(): + args.ep_url = os.getenv("OAPE_EP_URL", "").strip() + + scope = _scope_from_env() + extra_parts = [(scope.extra_context or "").strip()] if scope.extra_context else [] + + # Context file: can set project_name, repo_url, and/or scope description + if args.context_file: + pname, repourl, desc = load_context_from_file(args.context_file) + if pname is not None: + scope = ProjectScope(project_name=pname, repo_url=scope.repo_url, scope_description=scope.scope_description, extra_context=scope.extra_context) + if repourl is not None: + scope = ProjectScope(project_name=scope.project_name, repo_url=repourl, scope_description=scope.scope_description, extra_context=scope.extra_context) + if desc: + scope = ProjectScope(project_name=scope.project_name, repo_url=scope.repo_url, scope_description=desc, extra_context=scope.extra_context) + + # EP URL: fetch PR body and add to extra context + if args.ep_url: + ep_content = load_context_from_ep_url(args.ep_url) + if ep_content: + extra_parts.append(f"**Enhancement Proposal ({args.ep_url}):**\n\n{ep_content}") + else: + extra_parts.append(f"Enhancement Proposal URL (content could not be fetched; ensure gh CLI is installed and authenticated): {args.ep_url}") + + if extra_parts and any(extra_parts): + scope = ProjectScope( + project_name=scope.project_name, + repo_url=scope.repo_url, + scope_description=scope.scope_description, + extra_context="\n\n".join(p for p in extra_parts if p), + ) + + # CLI overrides (highest priority) + if args.project_name: + scope = ProjectScope(project_name=args.project_name, repo_url=scope.repo_url, scope_description=scope.scope_description, extra_context=scope.extra_context) + if args.repo_url: + scope = ProjectScope(project_name=scope.project_name, repo_url=args.repo_url, scope_description=scope.scope_description, extra_context=scope.extra_context) + if args.scope: + scope = ProjectScope(project_name=scope.project_name, repo_url=scope.repo_url, scope_description=args.scope, extra_context=scope.extra_context) + if args.extra: + scope = ProjectScope(project_name=scope.project_name, repo_url=scope.repo_url, scope_description=scope.scope_description, extra_context=args.extra) + + # Optional: inject repo layout so design/implementation use only existing paths + repo_path = (args.repo_path or os.getenv("OAPE_REPO_PATH") or os.getenv("OAPE_OPERATOR_CWD") or "").strip() + if repo_path: + repo_layout = get_repo_layout(repo_path) + if repo_layout: + scope = ProjectScope( + project_name=scope.project_name, + repo_url=scope.repo_url, + scope_description=scope.scope_description, + extra_context=scope.extra_context, + repo_path=repo_path, + repo_layout=repo_layout, + ) + print(f"Repo layout loaded from: {repo_path}\n") + + adapter = get_adapter(args.backend) + print(f"OAPE workflow backend: {adapter.backend_name}") + print(f"Project: {scope.project_name} | Repo: {scope.repo_url}\n") + result = adapter.run(scope) + + if not result.success: + print("Workflow failed:", result.error or "Unknown error") + if result.output_text: + print(result.output_text) + sys.exit(1) + + print("\n" + "=" * 70) + print("RESULT") + print("=" * 70) + print(result.output_text) + print("=" * 70) + + # Always print TRACE section so it's never missed + trace_id = result.artifacts.get("trace_id") if result.artifacts else None + trace_url = result.artifacts.get("trace_url") if result.artifacts else None + trace_access_code = result.artifacts.get("trace_access_code") if result.artifacts else None + print("\n" + "=" * 70, flush=True) + print("TRACE (CrewAI dashboard)", flush=True) + print("=" * 70, flush=True) + if trace_id or trace_url: + print("✅ Trace batch finalized with session ID:", trace_id or "", flush=True) + print("", flush=True) + print("🔗 View here:", trace_url or "", flush=True) + if trace_access_code: + print("🔑 Access Code:", trace_access_code, flush=True) + else: + print("(Not captured. Run: crewai login ; set CREWAI_TRACING_ENABLED=true)", flush=True) + print("=" * 70, flush=True) + + # Cost (from token usage when available) + artifacts = result.artifacts or {} + token_usage = artifacts.get("token_usage") + cost_usd = artifacts.get("estimated_cost_usd") + cost_estimate = artifacts.get("cost_estimate") + if token_usage is not None or cost_usd is not None: + print("\n" + "=" * 70, flush=True) + print("COST (estimated from token usage)", flush=True) + print("=" * 70, flush=True) + if token_usage: + print(f" Prompt tokens: {token_usage.get('prompt_tokens', 0):,}", flush=True) + print(f" Completion tokens: {token_usage.get('completion_tokens', 0):,}", flush=True) + print(f" Total tokens: {token_usage.get('total_tokens', 0):,}", flush=True) + if cost_usd is not None: + print(f" Estimated cost: ${cost_usd:.4f} USD", flush=True) + if cost_estimate and cost_estimate.get("model"): + print(f" Model (pricing): {cost_estimate.get('model', 'unknown')}", flush=True) + print("=" * 70, flush=True) + + if result.artifacts: + other = [k for k in result.artifacts if k not in ("trace_id", "trace_url", "trace_access_code", "token_usage", "estimated_cost_usd", "cost_estimate")] + if other: + print("\nArtifacts:", other) + + # Write outputs to repo/directory when --output-dir is set (so you see code changes after tasks complete) + output_dir = (args.output_dir or os.getenv("OAPE_OUTPUT_DIR", "").strip()) or None + if output_dir: + out_path = Path(output_dir).resolve() + out_path.mkdir(parents=True, exist_ok=True) + (out_path / "customer_doc.md").write_text(result.output_text or "", encoding="utf-8") + print(f"\nWrote customer_doc.md to {out_path}", flush=True) + if result.artifacts: + task_names = [ + "design", "design_review", "test_plan", "implementation_outline", + "unit_tests", "implementation", "quality_feedback", "code_review", "revision_summary", + "sse_writeup", "customer_doc", + ] + for i in range(1, 12): + key = f"task_{i}" + if key in result.artifacts and key not in ("trace_id", "trace_url", "trace_access_code"): + name = task_names[i - 1] if i <= len(task_names) else key + (out_path / f"task_{i}_{name}.md").write_text( + result.artifacts.get(key) or "", encoding="utf-8" + ) + print(f"Wrote task artifacts (task_1_*.md ... task_11_*.md) to {out_path}", flush=True) + + # Apply design as code changes in the operator repo: new branch, generated files, commit + if getattr(args, "apply_to_repo", False) or os.getenv("OAPE_APPLY_TO_REPO", "").strip().lower() in ("1", "true", "yes"): + if not repo_path: + print("\n⚠ --apply-to-repo requires --repo-path (or OAPE_REPO_PATH). Skipping.", flush=True) + elif args.backend != "crewai": + print("\n⚠ --apply-to-repo is only supported with backend=crewai. Skipping.", flush=True) + elif not result.artifacts: + print("\n⚠ No artifacts to apply (missing task outputs). Skipping.", flush=True) + else: + design = (result.artifacts.get("task_1") or "")[:20000] + impl = (result.artifacts.get("task_4") or "")[:20000] + revision = (result.artifacts.get("task_9") or "")[:15000] + unit_tests_md = (result.artifacts.get("task_5") or "")[:80000] + impl_code_md = (result.artifacts.get("task_6") or "")[:80000] + if not design or not impl: + print("\n⚠ Missing design (task_1) or implementation outline (task_4). Skipping apply.", flush=True) + else: + try: + from repo_apply import apply_to_repo + branch = args.branch_name or os.getenv("OAPE_BRANCH_NAME", "").strip() or None + ok, msg = apply_to_repo( + repo_path=repo_path, + design=design, + implementation_outline=impl, + revision_summary=revision, + repo_layout=scope.repo_layout if hasattr(scope, "repo_layout") else None, + project_name=scope.project_name, + branch_name=branch, + unit_tests_md=unit_tests_md or None, + implementation_code_md=impl_code_md or None, + ) + if ok: + print("\n" + "=" * 70, flush=True) + print("REPO APPLY", flush=True) + print("=" * 70, flush=True) + print("✅", msg, flush=True) + print("=" * 70, flush=True) + else: + print("\n❌ Repo apply failed:", msg, flush=True) + except Exception as e: + print("\n❌ Repo apply error:", e, flush=True) + + +if __name__ == "__main__": + main() diff --git a/crewai/personas.py b/crewai/personas.py new file mode 100644 index 0000000..6183656 --- /dev/null +++ b/crewai/personas.py @@ -0,0 +1,44 @@ +""" +Generic personas for the CrewAI workflow (project-agnostic). + +Agents take learnings from the skills loaded from plugins/oape/skills; +the skills context is injected into tasks and backstories at runtime. +""" + +SSE_PERSONA = { + "role": "Senior Software Engineer (OpenShift / Kubernetes)", + "goal": "Produce high-quality design documents, implementation outlines, and technical write-ups that align with project conventions and skills.", + "backstory": ( + "You are an experienced engineer who has shipped multiple OpenShift operators. " + "You follow the project's skills and conventions (Effective Go, API conventions, etc.) " + "when designing and outlining implementation. You address review feedback with concrete resolutions." + ), +} + +PSE_PERSONA = { + "role": "Principal Software Engineer (OpenShift / Zero Trust)", + "goal": "Review designs and implementation outlines for correctness, safety, and alignment with project standards; classify feedback as MUST, SHOULD, or NICE-TO-HAVE.", + "backstory": ( + "You are a principal engineer who sets the bar for production readiness. " + "You apply the project's skills and conventions when reviewing. " + "You give actionable, cited feedback and end with a clear verdict: Approved, Approved with changes, or Changes required." + ), +} + +SQE_PERSONA = { + "role": "Senior Quality Engineer (OpenShift)", + "goal": "Define test plans and test cases traceable to the design, and provide quality feedback on implementation outlines.", + "backstory": ( + "You ensure features are testable and production-ready. " + "You use the project's skills (e.g. testing patterns) when writing test cases and quality recommendations." + ), +} + +TECHNICAL_WRITER_PERSONA = { + "role": "Technical Writer (OpenShift / Customer-Facing Docs)", + "goal": "Turn engineer write-ups into clear, step-by-step customer documentation with prerequisites, verification, and troubleshooting.", + "backstory": ( + "You write for operators and developers who consume OpenShift documentation. " + "You keep the tone consistent and the steps actionable." + ), +} diff --git a/crewai/repo_apply.py b/crewai/repo_apply.py new file mode 100644 index 0000000..05919e2 --- /dev/null +++ b/crewai/repo_apply.py @@ -0,0 +1,544 @@ +""" +Apply OAPE workflow outputs to the operator repo: create a git branch, +generate code from design + implementation outline + revision summary, write files, and commit. + +Used when --apply-to-repo is set after a successful CrewAI run (requires --repo-path). +""" + +import json +import os +import re +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any, Optional + + +def _get_llm(): + """Use same LLM as agents (Vertex or OpenAI) for code generation.""" + from agents import _get_llm as agents_get_llm + return agents_get_llm() + + +def _llm_generate_files_prompt( + design: str, + implementation_outline: str, + revision_summary: str, + repo_layout: Optional[str], + project_name: str, +) -> str: + return f"""You are implementing an operator feature from an OAPE design and implementation outline. + +**Project:** {project_name} + +**Design document:** +``` +{design[:12000]} +``` + +**Implementation outline (files to add/modify, reconciliation logic, unit test plan):** +``` +{implementation_outline[:12000]} +``` + +**Revision summary (resolution of code review items):** +``` +{revision_summary[:8000]} +``` +""" + ( + f""" +**Repository layout (only create or modify files under these paths; paths are relative to repo root):** +``` +{repo_layout[:6000]} +``` +""" + if repo_layout + else "" + ) + """ + +**Task:** Produce the actual code and config changes. Output a single JSON object with this exact shape (no other text): +{"files": [{"path": "relative/path/from/repo/root", "content": "full file content as string"}]} + +Rules: +- "path" must be relative to the repository root (e.g. pkg/controller/foo/reconciler.go, config/crd/...). +- "content" must be the complete file content (not a diff). For existing files that you modify, output the full new content. +- Only include files that the implementation outline says to add or modify. Prefer existing paths from the repo layout when modifying. +- Use valid JSON only. Inside each "content" string use \\n for newlines (escaped), so the whole output is one parseable JSON block. +""" + + +def _llm_fix_compile_prompt( + compile_error: str, + repo_path: str, + files_touched: list[str], + design: str, + implementation_outline: str, + repo_layout: Optional[str], + project_name: str, +) -> str: + """Prompt for LLM to fix code so that go build succeeds.""" + files_list = "\n".join(f" - {p}" for p in files_touched[:30]) + return f"""The Go build failed in the repository. Fix the code so that `go build ./...` succeeds. + +**Project:** {project_name} +**Repo path:** {repo_path} + +**Build error:** +``` +{compile_error[:8000]} +``` + +**Files that were written (you may fix these or any other file the error points to, e.g. go.mod):** +{files_list or " (none listed)"} + +**Design (for context):** +``` +{(design or "")[:4000]} +``` + +**Implementation outline (for context):** +``` +{(implementation_outline or "")[:4000]} +``` +""" + ( + f""" +**Repo layout (paths relative to repo root):** +``` +{(repo_layout or "")[:3000]} +``` +""" + if repo_layout + else "" + ) + """ + +**Task:** Output a single JSON object. You may use either or both of "files" and "commands": + +1. **File edits:** {"files": [{"path": "relative/path", "content": "full file content"}]} + - Use for code fixes, go.mod version, etc. "path" is relative to repo root. Use \\n for newlines in "content". + +2. **Shell commands (run from repo root):** {"commands": ["go mod vendor"], "files": []} + - Use when the error says to run a command (e.g. "inconsistent vendoring", "run: go mod vendor", "use -mod=mod"). + - Typical fixes: ["go mod vendor"] to sync vendor dir, or ["go mod tidy"] to fix go.mod. + - You may output both "commands" and "files"; commands run first, then files are written, then build is retried. + +Example for vendoring error: {"commands": ["go mod vendor"], "files": []} +Example for code only: {"files": [{"path": "pkg/foo.go", "content": "..."}]} +""" + + +def _fix_compile_with_llm( + repo_path: str, + compile_stderr: str, + compile_stdout: str, + files_touched: list[str], + design: str, + implementation_outline: str, + revision_summary: str, + repo_layout: Optional[str], + project_name: str, +) -> tuple[list[dict[str, str]], list[str]]: + """Ask LLM to fix build. Returns (files to write, commands to run from repo root). Agent may suggest file edits and/or shell commands (e.g. go mod vendor).""" + err_text = (compile_stderr + "\n" + compile_stdout).strip() or "non-zero exit" + prompt = _llm_fix_compile_prompt( + compile_error=err_text, + repo_path=repo_path, + files_touched=files_touched, + design=design, + implementation_outline=implementation_outline, + repo_layout=repo_layout, + project_name=project_name, + ) + try: + llm = _get_llm() + if llm is None: + return [], [] + response = llm.call([{"role": "user", "content": prompt}]) + if not response or not isinstance(response, str): + return [], [] + return _parse_fix_response(response) + except Exception: + return [], [] + + +def _run_commands(repo_path: str, commands: list[str]) -> tuple[bool, str]: + """Run shell commands from repo root. Returns (success, message). Uses same env as go build.""" + repo = Path(repo_path).resolve() + if not repo.is_dir(): + return False, f"Repo path is not a directory: {repo}" + env = _go_build_env() + for cmd_str in commands: + if not cmd_str: + continue + try: + r = subprocess.run( + cmd_str, + cwd=repo, + shell=True, + env=env, + capture_output=True, + text=True, + timeout=120, + ) + if r.returncode != 0: + return False, f"Command failed: {cmd_str}\n{(r.stderr or r.stdout or '').strip()}" + except subprocess.TimeoutExpired: + return False, f"Command timed out: {cmd_str}" + except Exception as e: + return False, f"Command error ({cmd_str}): {e}" + return True, "" + + +def _list_written_paths(repo_path: str) -> list[str]: + """Return list of repo-relative paths that are modified/untracked (so we can tell the LLM what was written).""" + repo = Path(repo_path).resolve() + try: + r = subprocess.run( + ["git", "status", "--short", "--porcelain"], + cwd=repo, + capture_output=True, + text=True, + timeout=10, + ) + if r.returncode != 0: + return [] + paths = [] + for line in (r.stdout or "").strip().splitlines(): + # format: " M path" or "?? path" or "R old -> new" + parts = line.split(maxsplit=1) + if len(parts) >= 2: + p = parts[1].strip() + if " -> " in p: + p = p.split(" -> ", 1)[-1].strip() # rename: use destination path + paths.append(p) + return paths + except Exception: + return [] + + +def _parse_files_json(response: str) -> list[dict[str, str]]: + """Extract files list from LLM response (raw JSON or markdown code block).""" + _files, _ = _parse_fix_response(response) + return _files + + +def _parse_fix_response(response: str) -> tuple[list[dict[str, str]], list[str]]: + """Parse compile-fix LLM response. Returns (files, commands). Agent may return files and/or commands.""" + text = response.strip() + m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) + if m: + text = m.group(1).strip() + try: + data = json.loads(text) + if not isinstance(data, dict): + return [], [] + files = data.get("files") + if isinstance(files, list): + files = [f for f in files if isinstance(f, dict) and "path" in f and "content" in f] + else: + files = [] + commands = data.get("commands") + if isinstance(commands, list): + commands = [str(c).strip() for c in commands if c] + else: + commands = [] + return files, commands + except json.JSONDecodeError: + pass + return [], [] + + +def _extract_files_from_markdown(md: str) -> list[dict[str, str]]: + """Parse markdown with '## path' sections and ``` code blocks into [{path, content}].""" + files = [] + # Match ## path (optional) then optional text then ```lang? ... ``` + # Section: ## repo/relative/path.go or ## pkg/controller/foo_test.go + pattern = re.compile( + r"^##\s+(.+?)\s*$" # heading with path + r"(?:[\s\S]*?)" # any text until code block + r"```(?:\w*)\s*\n([\s\S]*?)```", + re.MULTILINE, + ) + for m in re.finditer(pattern, md): + path = m.group(1).strip().lstrip("/") + content = (m.group(2) or "").rstrip() + if not path or path.lower() in ("design", "implementation", "summary", "output", "rules"): + continue + if "/" in path or path.endswith(".go") or path.endswith(".yaml") or path.endswith(".yml"): + files.append({"path": path, "content": content}) + # Fallback: any ``` block with a path-like first line or filename in previous line + if not files: + for m in re.finditer(r"```(?:\w*)\s*\n([\s\S]*?)```", md): + content = (m.group(1) or "").rstrip() + if not content.strip(): + continue + # Try to find a path in the 5 lines before this block + start = max(0, m.start() - 500) + before = md[start:m.start()] + path_m = re.search(r"(?:^|\n)#{1,6}\s+([a-zA-Z0-9_/\.\-]+\.(?:go|yaml|yml))\s*$", before) + if path_m: + path = path_m.group(1).strip().lstrip("/") + files.append({"path": path, "content": content}) + return files + + +def _go_build_env() -> dict[str, str]: + """Build env for go build: inherit process env so GOROOT, GOPATH, GO111MODULE are used.""" + env = os.environ.copy() + # Ensure Go module mode is on if not set + if "GO111MODULE" not in env: + env["GO111MODULE"] = "on" + return env + + +def verify_compile(repo_path: str) -> tuple[bool, str, str, str]: + """Run go build ./... or make build. Returns (success, message, stderr, stdout).""" + repo = Path(repo_path).resolve() + if not repo.is_dir(): + return False, f"Repo path is not a directory: {repo}", "", "" + env = _go_build_env() + for cmd, args in [ + (["go", "build", "./..."], "go build ./..."), + (["make", "build"], "make build"), + ]: + try: + r = subprocess.run( + cmd, cwd=repo, capture_output=True, text=True, timeout=120, env=env + ) + stderr = r.stderr or "" + stdout = r.stdout or "" + if r.returncode == 0: + return True, f"Compile OK ({args}).", stderr, stdout + return False, f"Compile failed ({args}): {stderr or stdout or 'non-zero exit'}", stderr, stdout + except FileNotFoundError: + continue + except subprocess.TimeoutExpired: + return False, f"Compile timed out ({args}).", "", "" + return False, "No build command found (tried go build, make build).", "", "" + + +def create_branch(repo_path: str, branch_name: str) -> tuple[bool, str, Optional[str]]: + """Create and checkout a new git branch. If name already exists, use a unique suffix (e.g. -1, -2 or -HHMMSS). Returns (success, message, actual_branch_name).""" + repo = Path(repo_path).resolve() + if not repo.is_dir(): + return False, f"Repo path is not a directory: {repo}", None + for attempt in range(11): # base, then -1..-9, then -HHMMSS + if attempt == 0: + candidate = branch_name + elif attempt < 10: + candidate = f"{branch_name}-{attempt}" + else: + candidate = f"{branch_name}-{datetime.utcnow().strftime('%H%M%S')}" + try: + subprocess.run( + ["git", "checkout", "-b", candidate], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + if attempt == 0: + return True, f"Created and checked out branch: {candidate}", candidate + return True, f"Branch '{branch_name}' already existed; created and checked out: {candidate}", candidate + except subprocess.CalledProcessError as e: + err = (e.stderr or e.stdout or str(e)) or "" + if "already exists" in err.lower(): + continue + return False, f"git checkout -b failed: {err}", None + return False, f"Could not create a unique branch from '{branch_name}' (all attempts already exist).", None + + +def commit_changes(repo_path: str, message: str) -> tuple[bool, str]: + """Stage all changes and commit. Returns (success, message).""" + repo = Path(repo_path).resolve() + try: + subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True, text=True) + status = subprocess.run( + ["git", "status", "--short"], + cwd=repo, + capture_output=True, + text=True, + ) + if not status.stdout.strip(): + return False, "No changes to commit (working tree clean)." + subprocess.run( + ["git", "commit", "-m", message], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return True, f"Committed: {message}" + except subprocess.CalledProcessError as e: + return False, f"git commit failed: {e.stderr or e.stdout or str(e)}" + + +def _write_files(repo_path: str, files: list[dict[str, str]]) -> tuple[bool, str, int]: + """Write a list of {path, content} to repo. Returns (success, message, count).""" + repo = Path(repo_path).resolve() + written = 0 + for item in files: + rel = (item.get("path") or "").strip().lstrip("/") + content = item.get("content") or "" + if not rel: + continue + dest = repo / rel + try: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + written += 1 + except Exception as e: + return False, f"Failed to write {rel}: {e}", written + return True, f"Wrote {written} file(s).", written + + +def generate_and_apply_files( + repo_path: str, + design: str, + implementation_outline: str, + revision_summary: str, + repo_layout: Optional[str], + project_name: str, +) -> tuple[bool, str, int]: + """ + Use LLM to generate file contents from design + outline + revision, then write to repo. + Returns (success, message, number_of_files_written). + """ + repo = Path(repo_path).resolve() + if not repo.is_dir(): + return False, f"Repo path is not a directory: {repo}", 0 + + prompt = _llm_generate_files_prompt( + design=design, + implementation_outline=implementation_outline, + revision_summary=revision_summary, + repo_layout=repo_layout, + project_name=project_name, + ) + try: + llm = _get_llm() + if llm is None: + return False, "No LLM configured for code generation (set OPENAI_API_KEY or Vertex).", 0 + response = llm.call([{"role": "user", "content": prompt}]) + if not response or not isinstance(response, str): + return False, "LLM returned no or invalid response.", 0 + except Exception as e: + return False, f"LLM call failed: {e}", 0 + + files = _parse_files_json(response) + if not files: + return False, "LLM response did not contain a valid 'files' JSON array.", 0 + + return _write_files(repo_path, files) + + +def apply_to_repo( + repo_path: str, + design: str, + implementation_outline: str, + revision_summary: str, + repo_layout: Optional[str], + project_name: str, + branch_name: Optional[str] = None, + commit_message: Optional[str] = None, + unit_tests_md: Optional[str] = None, + implementation_code_md: Optional[str] = None, +) -> tuple[bool, str]: + """ + Create branch, write unit tests first then implementation (or LLM fallback), verify compile, commit. + Returns (success, message). Code must compile before commit. + """ + if not branch_name: + slug = re.sub(r"[^a-z0-9]+", "-", project_name.lower()).strip("-") or "feature" + date = datetime.utcnow().strftime("%Y%m%d") + branch_name = f"oape/{slug}-{date}" + if not commit_message: + commit_message = f"OAPE: apply design and implementation for {project_name} (tests first, then impl; code compiles)" + + ok, msg, actual_branch = create_branch(repo_path, branch_name) + if not ok: + return False, msg + actual_branch = actual_branch or branch_name + + total_written = 0 + # 1) SQE wrote unit tests first — extract and write + if unit_tests_md: + files = _extract_files_from_markdown(unit_tests_md) + if files: + ok, msg, n = _write_files(repo_path, files) + if not ok: + return False, msg + total_written += n + # 2) SSE wrote implementation — extract and write + if implementation_code_md: + files = _extract_files_from_markdown(implementation_code_md) + if files: + ok, msg, n = _write_files(repo_path, files) + if not ok: + return False, msg + total_written += n + # 3) Fallback: LLM-generated files if no files from task outputs + if total_written == 0: + ok, msg, n = generate_and_apply_files( + repo_path=repo_path, + design=design, + implementation_outline=implementation_outline, + revision_summary=revision_summary, + repo_layout=repo_layout, + project_name=project_name, + ) + if not ok: + return False, msg + total_written = n + + # 4) Code must compile before we commit; on failure, let the agent try to fix (up to N attempts) + max_fix_attempts = 3 + try: + env_attempts = os.getenv("OAPE_COMPILE_FIX_ATTEMPTS", "").strip() + if env_attempts: + max_fix_attempts = max(1, min(5, int(env_attempts))) + except ValueError: + pass + + for attempt in range(max_fix_attempts + 1): + ok, compile_msg, stderr, stdout = verify_compile(repo_path) + if ok: + break + if attempt >= max_fix_attempts: + return False, ( + f"Code did not compile after {max_fix_attempts} fix attempt(s). {compile_msg} " + "Export GOROOT, GOPATH, GO111MODULE if needed; or apply with --no-apply-to-repo." + ) + print(f"\n[Repo apply] Compile failed (attempt {attempt + 1}/{max_fix_attempts + 1}). Asking agent to fix ...", flush=True) + files_touched = _list_written_paths(repo_path) + fix_files, fix_commands = _fix_compile_with_llm( + repo_path=repo_path, + compile_stderr=stderr, + compile_stdout=stdout, + files_touched=files_touched, + design=design, + implementation_outline=implementation_outline, + revision_summary=revision_summary, + repo_layout=repo_layout, + project_name=project_name, + ) + if not fix_files and not fix_commands: + return False, f"Compile failed and agent could not produce a fix. {compile_msg}" + # Run agent-suggested commands first (e.g. go mod vendor), then apply file edits + if fix_commands: + ok_cmd, msg_cmd = _run_commands(repo_path, fix_commands) + if not ok_cmd: + return False, f"Agent suggested commands but they failed: {msg_cmd}" + print(f"[Repo apply] Ran agent commands: {fix_commands}", flush=True) + if fix_files: + ok_write, msg_write, n = _write_files(repo_path, fix_files) + if not ok_write: + return False, f"Compile fix write failed: {msg_write}" + print(f"[Repo apply] Applied agent file fix ({n} file(s))", flush=True) + print("[Repo apply] Re-running build ...", flush=True) + # retry verify_compile on next iteration + + ok, commit_msg = commit_changes(repo_path, commit_message) + if not ok: + return False, f"Files written and compile OK, but commit failed: {commit_msg}" + + return True, f"Branch '{actual_branch}' created, {total_written} file(s) written, compile OK, committed." diff --git a/crewai/requirements.txt b/crewai/requirements.txt new file mode 100644 index 0000000..641dc86 --- /dev/null +++ b/crewai/requirements.txt @@ -0,0 +1,7 @@ +# OAPE CrewAI workflow — project-agnostic, uses skills from plugins/oape/skills +crewai>=0.80.0 +python-dotenv>=1.0.0 + +# Optional: Vertex AI (set OAPE_CREWAI_USE_VERTEX=1) +anthropic[vertex]>=0.39.0 +google-cloud-aiplatform>=1.38.0 diff --git a/crewai/run_test_with_repo.sh b/crewai/run_test_with_repo.sh new file mode 100755 index 0000000..782d7dd --- /dev/null +++ b/crewai/run_test_with_repo.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Run OAPE CrewAI workflow with ZTWIM scope and local repo path (so SSE uses real paths). +# Ensure ztwim-repo is cloned (from workspace root: ./scripts/clone_ztwim_repo.sh). + +set -e +CREWAI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# ztwim-repo is at multi-agent-orchestration/ztwim-repo; from oape-ai-e2e/crewai that's ../../ztwim-repo +REPO_PATH="${REPO_PATH:-${CREWAI_DIR}/../../ztwim-repo}" + +cd "$CREWAI_DIR" +if [[ ! -d "$REPO_PATH" ]]; then + echo "Repo not found: $REPO_PATH" + echo "Clone it from workspace root: ./scripts/clone_ztwim_repo.sh" + exit 1 +fi +echo "Using repo path: $REPO_PATH" +python main.py --context-file scope_ztwim_upstream_authority.txt --repo-path "$REPO_PATH" "$@" diff --git a/crewai/scope_ztwim_test_small.txt b/crewai/scope_ztwim_test_small.txt new file mode 100644 index 0000000..b633741 --- /dev/null +++ b/crewai/scope_ztwim_test_small.txt @@ -0,0 +1,10 @@ +PROJECT_NAME=Zero Trust Workload Identity Manager (ZTWIM) +REPO_URL=https://github.com/anirudhAgniRedhat/openshift-zero-trust-workload-identity-manager +--- +Small test change for OAPE workflow: add a single constant and unit test only. + +Scope (minimal, for testing the pipeline): +- Add one exported constant in the ZTWIM codebase, e.g. in pkg/ or a small types file: TestFeatureFlag or OAPETestConstant = "oape-test-v1". +- Add one unit test that asserts the constant has the expected value. +- No CRD changes, no reconciliation logic, no new APIs. This is only to verify the OAPE workflow (design → review → tests first → implementation → compile → apply to repo) runs end-to-end in the ZTWIM repo. +- Prefer an existing package under the repo layout; do not create many new files. diff --git a/crewai/scope_ztwim_upstream_authority.txt b/crewai/scope_ztwim_upstream_authority.txt new file mode 100644 index 0000000..969b218 --- /dev/null +++ b/crewai/scope_ztwim_upstream_authority.txt @@ -0,0 +1,20 @@ +PROJECT_NAME=Zero Trust Workload Identity Manager (ZTWIM) +REPO_URL=https://github.com/anirudhAgniRedhat/openshift-zero-trust-workload-identity-manager +--- +Integration with Upstream Authority for the SPIRE server: support for cert-manager, SPIRE (nested), and Vault only. + +Background: +- ZTWIM is the OpenShift Zero Trust Workload Identity Manager. +- The feature is to add upstream authority plugin support in ZTWIM so the SPIRE server can use cert-manager, SPIRE (nested), or Vault as upstream CAs. +- Reference: SPIRE docs for plugin_server_upstreamauthority_cert_manager, plugin_server_upstreamauthority_spire, plugin_server_upstreamauthority_vault. + +Requirements: +- Design: API/CRD and operator reconciliation for the three upstream authority types (cert-manager, SPIRE, Vault). +- The API chnages should be part of SpireServer CRD Add the changes in ztwim-repo/api/v1alpha1/spire_server_config_types.go file only do not overwrite anu existing API chnages. +- DO NOT ADD any seperate condition API chnages for status sub resource. Use the existing conditions if there is any failure to report. +- Do NOT WRITE tests for the API Chnages so far. +- Scope of operator is not to create any secret if there is needed user will create the secret and give the secret reference for us. +- Follow OpenShift and SPIRE conventions; consider security and operational concerns. +- Test plan and test cases must be traceable to the design and to each upstream authority variant. +- Implementation outline should cover files/packages to add or modify, reconciliation logic, and unit test plan—no full code, outline only. +- Documentation must support customer-facing steps: configuration, verification, troubleshooting for each upstream authority option. diff --git a/crewai/scripts/test_crewai.sh b/crewai/scripts/test_crewai.sh new file mode 100755 index 0000000..ac53d29 --- /dev/null +++ b/crewai/scripts/test_crewai.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Quick tests for the OAPE CrewAI workflow. +# Run from oape-ai-e2e/crewai: ./scripts/test_crewai.sh [smoke|context|output-dir|apply] + +set -e +CREWAI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$CREWAI_DIR" + +export OAPE_MAX_REASONING_ATTEMPTS="${OAPE_MAX_REASONING_ATTEMPTS:-3}" + +case "${1:-smoke}" in + smoke) + echo "=== Smoke test: default scope, 11 tasks (no context file) ===" + python main.py + ;; + context) + echo "=== Test with context file (example_scope.txt) ===" + python main.py --context-file example_scope.txt + ;; + output-dir) + echo "=== Test with --output-dir (writes task_1..task_11 to /tmp/oape-test) ===" + python main.py --context-file example_scope.txt --output-dir /tmp/oape-test + echo "Outputs in: /tmp/oape-test" + ;; + apply) + REPO_PATH="${REPO_PATH:-}" + if [[ -z "$REPO_PATH" ]]; then + echo "Usage: REPO_PATH=/path/to/operator-repo $0 apply" + echo " Creates branch, writes code from SQE/SSE tasks, runs go build, commits." + exit 1 + fi + echo "=== Test with --apply-to-repo (branch + code + compile + commit) ===" + python main.py --context-file example_scope.txt --repo-path "$REPO_PATH" --apply-to-repo + ;; + *) + echo "Usage: $0 {smoke|context|output-dir|apply}" + echo " smoke - default scope, 11 tasks (fastest)" + echo " context - example_scope.txt" + echo " output-dir - write task outputs to /tmp/oape-test" + echo " apply - apply to repo (set REPO_PATH=/path/to/operator-repo)" + exit 1 + ;; +esac diff --git a/crewai/skills_loader.py b/crewai/skills_loader.py new file mode 100644 index 0000000..b5e908f --- /dev/null +++ b/crewai/skills_loader.py @@ -0,0 +1,92 @@ +""" +Load all skills from plugins/oape/skills/*/SKILL.md for use in CrewAI agents and tasks. + +Skills are reusable guidelines (e.g. Effective Go, API conventions) that agents +should apply when generating or reviewing content. This loader discovers every +SKILL.md under the oape skills directory and returns a single context string. +""" + +import os +from pathlib import Path +from typing import Optional + + +def _find_skills_dir() -> Path: + """Resolve plugins/oape/skills relative to this file (crewai/skills_loader.py).""" + # crewai/skills_loader.py -> oape-ai-e2e/crewai/ -> oape-ai-e2e/ + crewai_dir = Path(__file__).resolve().parent + repo_root = crewai_dir.parent + return repo_root / "plugins" / "oape" / "skills" + + +def load_skills_context() -> str: + """ + Load all SKILL.md files under plugins/oape/skills//SKILL.md. + Returns a single markdown string with each skill's content under a heading. + """ + skills_dir = _find_skills_dir() + if not skills_dir.is_dir(): + return "" + + parts = [] + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + skill_file = skill_dir / "SKILL.md" + if not skill_file.is_file(): + continue + try: + content = skill_file.read_text(encoding="utf-8") + name = skill_dir.name + parts.append(f"## Skill: {name}\n\n{content}") + except Exception: + continue + + if not parts: + return "" + return "\n\n---\n\n".join(parts) + + +def get_skills_context_for_prompt(max_chars: Optional[int] = None) -> str: + """ + Same as load_skills_context(), but with a short instruction prefix. + Truncated to stay under token limits. Set OAPE_SKILLS_MAX_CHARS to override (default 6000). + Used when injecting skills into every task (legacy); prefer get_skills_context_for_agents(). + """ + if max_chars is None: + try: + max_chars = max(1000, int(os.getenv("OAPE_SKILLS_MAX_CHARS", "6000").strip())) + except ValueError: + max_chars = 6000 + raw = load_skills_context() + if not raw: + return "" + if len(raw) > max_chars: + raw = raw[:max_chars] + "\n\n[... skills truncated for context window ...]" + return ( + "Apply the following skills and conventions where relevant. " + "These are shared learnings from the project's skills directory.\n\n" + + raw + ) + + +def get_skills_context_for_agents(max_chars: Optional[int] = None) -> str: + """ + Skills context to inject once per agent (in backstory), not in every task. + Larger limit than for tasks (default 12000) since it is sent only 4 times (one per agent). + Set OAPE_SKILLS_FOR_AGENTS_MAX_CHARS to override. + """ + if max_chars is None: + try: + max_chars = max(2000, int(os.getenv("OAPE_SKILLS_FOR_AGENTS_MAX_CHARS", "12000").strip())) + except ValueError: + max_chars = 12000 + raw = load_skills_context() + if not raw: + return "" + if len(raw) > max_chars: + raw = raw[:max_chars] + "\n\n[... skills truncated ...]" + return ( + "\n\nWhen generating or reviewing code, apply these project conventions:\n\n" + + raw + ) diff --git a/crewai/tasks.py b/crewai/tasks.py new file mode 100644 index 0000000..e53f6c6 --- /dev/null +++ b/crewai/tasks.py @@ -0,0 +1,175 @@ +""" +CrewAI tasks for the OAPE workflow (project-agnostic). + +Skills and repository layout are in agent backstory when provided; task descriptions get scope +(optionally without repeating the project tree) and command prompts only. +""" + +from typing import List, Optional + +from crewai import Agent, Task + +from agents import sse, pse, sqe, technical_writer +from command_prompts_loader import get_prompt_for_task +from context import ProjectScope + + +def build_tasks( + scope: ProjectScope, + agents: Optional[List[Agent]] = None, + scope_include_repo_layout: Optional[bool] = None, +): + """ + Build the 11-task workflow with the given project scope. + When agents is provided (e.g. built with repo_layout in backstory), scope excludes repo layout + to avoid duplicating the project tree in every task. + """ + sse_agent = (agents[0] if agents and len(agents) >= 4 else sse) + pse_agent = (agents[1] if agents and len(agents) >= 4 else pse) + sqe_agent = (agents[2] if agents and len(agents) >= 4 else sqe) + tw_agent = (agents[3] if agents and len(agents) >= 4 else technical_writer) + # When agents have repo_layout in backstory, do not repeat it in scope + include_layout = scope_include_repo_layout if scope_include_repo_layout is not None else (agents is None or not scope.repo_layout) + scope_md = scope.to_markdown(include_repo_layout=include_layout) + # One-line reminder so agents use their backstory conventions (no large skills block per task) + conventions_note = " Apply the project's conventions (Effective Go, API conventions) from your role context." + + def _desc(prefix: str, rest: str, task_kind: str = "") -> str: + base = f"{prefix}{conventions_note}\n\n{rest}" + cmd_prompt = get_prompt_for_task(task_kind) if task_kind else "" + if cmd_prompt: + base = base + "\n\n" + cmd_prompt + return base + + # --- Task 1: SSE — Design --- + t1 = Task( + description=_desc( + f"Create the **design document** for the feature in scope.\n\n{scope_md}", + "Your design MUST include: (1) Overview and goals, (2) API/CRD changes with example YAML, " + "(3) Operator reconciliation, (4) Security and operational considerations, (5) Open questions. " + "Output: Markdown, no placeholders.", + "design", + ), + expected_output="Design document in Markdown with the five sections above.", + agent=sse_agent, + ) + + # --- Task 2: PSE — Design review --- + t2 = Task( + description=_desc( + "Review the SSE's design document (in your context). Principal Engineer **design review**.", + "Evaluate correctness, completeness, security. For each finding use MUST / SHOULD / NICE-TO-HAVE. " + "In Summary, end with exactly one verdict on its own line: **Approved**, **Approved with changes**, or **Changes required**.", + "design_review", + ), + expected_output="Design review with MUST/SHOULD/NICE-TO-HAVE and Summary (verdict as last line).", + agent=pse_agent, + context=[t1], + ) + + # --- Task 3: SQE — Test cases --- + t3 = Task( + description=_desc( + "Using the design and design review (in your context), create the **test plan and test cases**.", + "For each test case: ID, Title, Preconditions, Steps, Expected result. Traceable to design. Output: Markdown.", + "test_cases", + ), + expected_output="Test plan and test cases in Markdown.", + agent=sqe_agent, + context=[t1, t2], + ) + + # --- Task 4: SSE — Implementation outline --- + t4_desc = "Using design, review, and test cases (in your context), produce the **implementation outline and unit test plan**." + t4_rest = "Include: (1) Files/packages to add or modify, (2) Reconciliation logic outline, (3) Unit test plan. No full code; outline only. SQE will write unit tests first, then SSE will write implementation logic; the final code must compile. Output: Markdown." + if scope.repo_layout: + t4_rest += " Use the repository layout from your role context; suggest ONLY paths that exist in that layout (do not invent directories)." + t4 = Task( + description=_desc( + t4_desc, + t4_rest, + "implementation_outline", + ), + expected_output="Implementation outline and unit test plan in Markdown.", + agent=sse_agent, + context=[t1, t2, t3], + ) + + # --- Task 5: SQE — Write unit test code first --- + t5_desc = "Using the design, test plan, and implementation outline (in your context), **write the unit test code first**. Produce the actual test files (e.g. *_test.go) that will exercise the implementation. Do not write production code yet; SSE will implement the logic next so that these tests pass and the code compiles." + t5_rest = "Output: Markdown with one section per file. Each section must have a heading with the repo-relative file path (e.g. ## pkg/controller/foo/foo_test.go) followed by a code block containing the full file content. Ensure test code is valid and would compile once stubs or implementation exist." + if scope.repo_layout: + t5_rest += " Use ONLY paths from the repository layout in your role context (or new files under existing packages)." + t5 = Task( + description=_desc(t5_desc, t5_rest, "unit_tests"), + expected_output="Unit test code as Markdown (sections with file path and code block per file).", + agent=sqe_agent, + context=[t1, t2, t3, t4], + ) + + # --- Task 6: SSE — Write implementation logic (code must compile) --- + t6_desc = "Using the design, implementation outline, and the **unit tests written by SQE** (in your context), **write the implementation logic** so that the unit tests pass and **the code compiles**. Produce the actual production code (reconciler, CRD, etc.); do not leave stubs. The final codebase must build successfully (e.g. go build ./...)." + t6_rest = "Output: Markdown with one section per file. Each section must have a heading with the repo-relative file path (e.g. ## pkg/controller/foo/reconciler.go) followed by a code block containing the full file content. Align with the implementation outline and ensure all SQE tests can pass." + if scope.repo_layout: + t6_rest += " Use ONLY paths from the repository layout in your role context (or new files under existing packages)." + t6 = Task( + description=_desc(t6_desc, t6_rest, "implementation"), + expected_output="Implementation code as Markdown (sections with file path and code block per file). Code must compile.", + agent=sse_agent, + context=[t1, t2, t3, t4, t5], + ) + + # --- Task 7: SQE — Quality --- + t7 = Task( + description=_desc( + "Using design, test cases, implementation outline, unit test code, and implementation code (in your context), produce **quality feedback and recommendations**.", + "Test execution summary (Pass/Fail/Blocked per case), quality issues (High/Medium/Low), recommended improvements. Note whether the code would compile. Output: Markdown.", + ), + expected_output="Quality feedback and recommendations in Markdown.", + agent=sqe_agent, + context=[t1, t2, t3, t4, t5, t6], + ) + + # --- Task 8: PSE — Code review --- + t8 = Task( + description=_desc( + "Review the implementation outline, unit test code, and implementation code (in your context). Principal Engineer **code/implementation review**. Ensure the code compiles and tests are appropriate.", + "Evaluate correctness, security, alignment with design. MUST / SHOULD / NICE-TO-HAVE. " + "In Summary, end with exactly one verdict: **Approved**, **Approved with changes**, or **Changes required**.", + "code_review", + ), + expected_output="Code review with MUST/SHOULD/NICE-TO-HAVE and Summary (verdict as last line).", + agent=pse_agent, + context=[t4, t5, t6, t7], + ) + + # --- Task 9: SSE — Address review --- + t9_desc = _desc( + "Using the PSE code review (in your context), produce the **revision summary**: for each MUST and SHOULD, state your resolution (fix, document, or defer). Output: Markdown, section « Resolution of review items ».", + "", + "address_review", + ) + t9 = Task( + description=t9_desc, + expected_output="Resolution for each MUST/SHOULD from code review.", + agent=sse_agent, + context=[t4, t5, t6, t7, t8], + ) + + # --- Task 10: SSE — Write-up for Technical Writer --- + t10 = Task( + description="Using the full context (design, reviews, test plan, implementation outline, unit tests, implementation code, quality feedback, revision summary), produce a **structured write-up for the Technical Writer**: summary, prerequisites, configuration steps, verification, troubleshooting, references. Do not write final prose; provide structured content. Output: Markdown.", + expected_output="Structured write-up for docs in Markdown.", + agent=sse_agent, + context=[t1, t2, t3, t4, t5, t6, t7, t8, t9], + ) + + # --- Task 11: Technical Writer — Customer doc --- + t11 = Task( + description="Using the SSE write-up (in your context), create the **customer-facing documentation**: overview, prerequisites, step-by-step configuration, verification, troubleshooting. Markdown suitable for publication.", + expected_output="Customer-facing Markdown documentation.", + agent=tw_agent, + context=[t10], + ) + + return [t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11]