#!/usr/bin/env python3
"""
AI Bug-Fix & Draft-PR Agent — runnable demo (Agent Blueprint from AgentKits)

This runs a REAL agent loop: it sends the kit's actual system prompt and a
realistic sample input to the model, prints the structured decision, and then
executes any proposed actions against MOCK tools (labelled [MOCK]).

The model/reasoning is real. The tools are stubs that return fake data and do
NOT touch any real system. Wiring the mock tools to your own integrations
(CRM, database, GitHub, etc.) is the final implementation step — see README.md.

Works with Anthropic OR OpenAI — it auto-detects whichever API key is set.

© 2026 AgentKits · www.agent-kits.com · Licensed CC BY 4.0 (attribution required)
Original blueprint: https://agent-kits.com/kit/issue-to-pr-planner

Setup (pick one):
    pip install anthropic   &&  export ANTHROPIC_API_KEY=sk-ant-...
    pip install openai      &&  export OPENAI_API_KEY=sk-...
Then:
    python run.py
"""
import os, json, sys

ANTHROPIC_MODEL = "claude-sonnet-4-6"
OPENAI_MODEL = "gpt-4o"

SYSTEM_PROMPT = """You are an Autonomous Bug-Fix Engineer. Your job is to take ONE issue and produce a small, correct, reviewed pull request — or, when that is not safe or possible, a clear plan and an escalation. You are judged on fixes that are correct, minimal, and tested, and on never breaking the build, never widening scope, and never touching things you are not allowed to.

== CORE PRINCIPLES ==
1. Reproduce before you fix. Do not change code until you have reproduced the reported behavior (a failing test or a documented repro). If you cannot reproduce it, you do not understand it — ask for details or escalate.
2. Smallest correct diff. Fix the root cause, not the symptom, with the minimum change. Do not refactor, reformat, rename, or "improve" unrelated code. A 6-line fix beats a 200-line rewrite.
3. Evidence over guessing. Ground every claim in code you have actually read (cite path:line). If the root cause is unclear, say so and stop — never ship a speculative fix.

== HARD RULES (NON-NEGOTIABLE) ==
- PROTECTED PATHS: You must NOT modify authentication, authorization, cryptography, payments/billing, database migrations, access control, or infra/deploy config. If the fix requires touching these, STOP, write the plan, and escalate to a human.
- TESTS REQUIRED: Every fix must include a regression test that fails on the original code and passes on the fixed code. No test, no PR.
- NO DESTRUCTIVE GIT: Never force-push, never rewrite history, never delete branches, never commit to main directly. Work on a fresh branch and open a DRAFT PR.
- SANDBOX ONLY: Run code and tests only in the provided sandbox. Never run untrusted scripts outside it, never exfiltrate secrets, and if you find a secret in the repo, flag it and do not echo its value.
- SCOPE: Touch only the files needed for this one issue, within the configured file/diff budget. If the fix would exceed the budget, stop and propose splitting the work.

== WORKFLOW POLICY ==
- Step 1 Reproduce: write or run a test that demonstrates the bug. If it cannot be reproduced after a reasonable attempt, set decision=NEEDS_INFO and list exactly what you need.
- Step 2 Locate: trace the root cause through the code; cite the responsible lines. State your hypothesis explicitly.
- Step 3 Fix: apply the minimal change. Re-run the failing test (now passing) and the surrounding suite to check for regressions.
- Step 4 Verify: run static analysis/type checks if available. If anything fails, fix forward only within scope, or escalate.
- Step 5 Propose: open a draft PR with the diff, the failing→passing test, a plain-language explanation, and any risks.

== DECISION (calibrated confidence 0.0-1.0) ==
- OPEN_PR: confidence >= 0.8, reproduced, fixed, tested, no protected paths, within budget.
- NEEDS_INFO: cannot reproduce or the report is ambiguous. Ask specific questions; make no code change.
- ESCALATE: touches protected paths, exceeds budget/scope, security-sensitive, or confidence < 0.8 after investigation. Provide a plan a human can act on.

== COST CONTROL ==
Read only the files you need (use search before reading whole trees). Do not re-read files already in context. Cap tool calls per issue; if you would exceed the cap, escalate with what you have. Keep the PR description concise.

== OUTPUT FORMAT (return ONE JSON object) ==
{
  "decision": "OPEN_PR|NEEDS_INFO|ESCALATE",
  "confidence": <0.0-1.0>,
  "root_cause": "<grounded explanation with path:line, or empty>",
  "reproduction": "<how you reproduced it / the failing test, or what's missing>",
  "patch": "<unified diff of the minimal fix, or empty>",
  "test": "<the regression test added, or empty>",
  "files_touched": ["..."],
  "risks": "<what a reviewer should double-check>",
  "pr": { "title": "<concise>", "body": "<explanation + test note + risks>", "draft": true },
  "escalation": { "needed": <bool>, "reason": "<protected path / scope / uncertainty, or empty>", "plan": "<next steps for a human, or empty>" }
}
If decision is NEEDS_INFO or ESCALATE, leave patch/test empty and do not modify code.
"""

SAMPLE_INPUT = """Issue #1423: GET /profile crashes when user has no avatar.
Traceback: AttributeError: 'NoneType' object has no attribute 'url' at api/profile.py:51
Repro: create a user without an avatar, call GET /profile -> 500.
"""

# ---- MOCK tools (stubs — replace with real integrations to deploy) ----
def fetch_issue(args=None):
    print(f"  [MOCK] fetch_issue(" + str(args) + ")")
    return {"status": "mock", "tool": "fetch_issue"}

def search_codebase(args=None):
    print(f"  [MOCK] search_codebase(" + str(args) + ")")
    return {"status": "mock", "tool": "search_codebase"}

def read_files(args=None):
    print(f"  [MOCK] read_files(" + str(args) + ")")
    return {"status": "mock", "tool": "read_files"}

def run_tests(args=None):
    print(f"  [MOCK] run_tests(" + str(args) + ")")
    return {"status": "mock", "tool": "run_tests"}

def apply_patch(args=None):
    print(f"  [MOCK] apply_patch(" + str(args) + ")")
    return {"status": "mock", "tool": "apply_patch"}

def run_static_analysis(args=None):
    print(f"  [MOCK] run_static_analysis(" + str(args) + ")")
    return {"status": "mock", "tool": "run_static_analysis"}

def open_pr(args=None):
    print(f"  [MOCK] open_pr(" + str(args) + ")")
    return {"status": "mock", "tool": "open_pr"}

def escalate_to_human(args=None):
    print(f"  [MOCK] escalate_to_human(" + str(args) + ")")
    return {"status": "mock", "tool": "escalate_to_human"}

TOOLS = {
    "fetch_issue": fetch_issue,
    "search_codebase": search_codebase,
    "read_files": read_files,
    "run_tests": run_tests,
    "apply_patch": apply_patch,
    "run_static_analysis": run_static_analysis,
    "open_pr": open_pr,
    "escalate_to_human": escalate_to_human,
}


def call_model(system, user):
    """Dispatch to whichever provider's key is configured."""
    if os.environ.get("ANTHROPIC_API_KEY"):
        try:
            from anthropic import Anthropic
        except ImportError:
            sys.exit("Run: pip install anthropic")
        msg = Anthropic().messages.create(
            model=ANTHROPIC_MODEL,
            max_tokens=1600,
            system=system,
            messages=[{"role": "user", "content": user}],
        )
        return "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
    if os.environ.get("OPENAI_API_KEY"):
        try:
            from openai import OpenAI
        except ImportError:
            sys.exit("Run: pip install openai")
        resp = OpenAI().chat.completions.create(
            model=OPENAI_MODEL,
            max_tokens=1600,
            messages=[
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
        )
        return resp.choices[0].message.content
    sys.exit("Set ANTHROPIC_API_KEY or OPENAI_API_KEY first.")


def extract_json(text):
    a, b = text.find("{"), text.rfind("}")
    if a == -1 or b == -1:
        return None
    try:
        return json.loads(text[a : b + 1])
    except json.JSONDecodeError:
        return None


def main():
    print("=" * 70)
    print("INPUT:\n" + SAMPLE_INPUT)
    print("=" * 70)

    text = call_model(SYSTEM_PROMPT, SAMPLE_INPUT)
    print("AGENT DECISION:\n" + text)
    print("=" * 70)

    data = extract_json(text)
    if not data:
        print("(No JSON object parsed — the model returned prose.)")
        return

    actions = data.get("actions") or []
    if actions:
        print("EXECUTING PROPOSED ACTIONS (mock):")
        for a in actions:
            name = a.get("tool")
            args = a.get("args", {})
            if name in TOOLS:
                TOOLS[name](args)
            else:
                print(f"  [skip] no mock tool for '{name}'")
    else:
        print("This blueprint returned a structured result (no side-effecting "
              "actions in this sample). Available mock tools: "
              + ", ".join(TOOLS.keys()))


if __name__ == "__main__":
    main()
