auto-dev-agent / agents /planning_agent.py
Siva sai Yadav
ready for HuggingFace Space deployment
8edee29
Raw
History Blame Contribute Delete
10.7 kB
"""
agents/planning_agent.py
------------------------
Planning Agent for AutoDevAgent.
Responsible for two things:
1. Breaking the user's task into a structured, step-by-step plan
before any code is written. This is the ReAct "Reason" step β€”
the agent thinks before it acts.
2. For SQL tasks: inferring the database schema (tables, columns,
dummy data) needed to execute and test the query.
Design:
- Uses Groq's primary model (Llama 3.1 70B) for quality reasoning.
- Returns fully typed Pydantic models β€” no raw strings leave this module.
- Prompt instructs the LLM to respond in JSON only, which we parse
into PlanStep / SQLSchema models via Pydantic validation.
Usage:
from agents.planning_agent import PlanningAgent
from pipeline.state import PipelineState, Language
agent = PlanningAgent()
state = PipelineState(task="reverse a string", language=Language.PYTHON)
updated = agent.run(state)
print(updated["plan"]) # List[PlanStep]
print(updated["sql_schema"]) # SQLSchema | None
"""
import json
import logging
from typing import Any
from langchain_groq import ChatGroq
from langchain_core.messages import SystemMessage, HumanMessage
from pydantic import ValidationError
from config import settings
from pipeline.state import (
PipelineState,
PipelineStatus,
PlanStep,
SQLSchema,
Language,
)
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------ #
# Prompts #
# ------------------------------------------------------------------ #
PYTHON_PLAN_SYSTEM = """
You are a senior software engineer planning how to solve a coding task.
Your job is to break the task into clear, logical steps BEFORE writing any code.
Return ONLY a JSON object in this exact format β€” no markdown, no explanation:
{
"steps": [
{
"step_number": 1,
"description": "What this step does in plain English",
"reasoning": "Why this step is necessary"
}
]
}
Rules:
- Between 3 and 7 steps. No more, no less.
- Each step should be a single, clear action.
- reasoning must explain WHY, not just repeat the description.
- Do not write any code in the plan.
""".strip()
SQL_PLAN_SYSTEM = """
You are a senior data engineer planning how to solve a SQL task.
Your job is to:
1. Infer what database tables and columns the query will need.
2. Break the task into clear steps.
3. Generate dummy data that is GUARANTEED to satisfy the query conditions.
Return ONLY a JSON object in this exact format β€” no markdown, no explanation:
{
"steps": [
{
"step_number": 1,
"description": "What this step does in plain English",
"reasoning": "Why this step is necessary"
}
],
"schema": {
"create_statements": [
"CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, revenue REAL);"
],
"insert_statements": [
"INSERT INTO customers VALUES (1, 'Alice', 5000.0);",
"INSERT INTO customers VALUES (2, 'Bob', 3200.0);"
],
"table_descriptions": [
"customers β€” stores customer name and total revenue"
]
}
}
Rules:
- Between 3 and 7 steps.
- Schema must include at least 6 realistic dummy rows per table.
- Use standard SQLite-compatible SQL only.
- Do not include any explanation outside the JSON.
CRITICAL RULE β€” DATE-BASED QUERIES:
If the query filters by a date range (e.g. "last 30 days", "last 7 days", "this month",
"this year", "yesterday"), you MUST use SQLite relative date expressions in INSERT statements
so the dummy data is always within the filter window.
Use: date('now', '-N days') β€” NOT hardcoded dates like '2024-01-15'
Examples for "last 30 days":
CORRECT: INSERT INTO sales VALUES (1, 'ProductA', 500.0, date('now', '-5 days'));
CORRECT: INSERT INTO sales VALUES (2, 'ProductB', 300.0, date('now', '-12 days'));
CORRECT: INSERT INTO sales VALUES (3, 'ProductC', 200.0, date('now', '-20 days'));
WRONG: INSERT INTO sales VALUES (1, 'ProductA', 500.0, '2024-01-15'); ← NEVER do this
For "last 30 days" queries: spread dates from date('now', '-1 days') to date('now', '-28 days').
For "last 7 days": use date('now', '-1 days') through date('now', '-6 days').
For "this month": use date('now', 'start of month') plus a few days offset.
Generate enough rows (at least 6) so LIMIT/TOP-N queries return full results.
""".strip()
# ------------------------------------------------------------------ #
# Agent #
# ------------------------------------------------------------------ #
class PlanningAgent:
"""
Breaks a user task into a typed step-by-step plan.
For SQL tasks, also infers the schema and generates dummy data
that will be loaded into SQLite before query execution.
Attributes:
llm: ChatGroq instance using the primary model (70B).
"""
def __init__(self) -> None:
"""LLM client is built lazily in run() once model assignments are known."""
self.llm = None
def _build_llm(self, model: str) -> "ChatGroq":
return ChatGroq(
api_key=settings.groq_api_key,
model=model,
temperature=0.2,
max_tokens=1500,
request_timeout=settings.groq_request_timeout,
)
def run(self, state: PipelineState) -> dict[str, Any]:
"""
Run the planning agent on the current pipeline state.
Sends the task to the LLM with a language-specific system prompt,
parses the JSON response into typed Pydantic models, and returns
a partial state dict that LangGraph merges back into the full state.
Args:
state: The current PipelineState. Reads: task, language.
Returns:
Partial state dict with keys:
- "plan": List[PlanStep]
- "sql_schema": SQLSchema | None (SQL tasks only)
- "status": PipelineStatus.PLANNING
"""
ma = state.model_assignments or {}
model = ma.get("planner", settings.groq_model_primary)
self.llm = self._build_llm(model)
logger.info("PlanningAgent using model: %s", model)
logger.info("PlanningAgent running for task: %s", state.task[:60])
# Select the right system prompt based on language
system_prompt = (
SQL_PLAN_SYSTEM
if state.language == Language.SQL
else PYTHON_PLAN_SYSTEM
)
messages = [
SystemMessage(content=system_prompt),
HumanMessage(content=f"Task: {state.task}"),
]
# ── LLM call ──────────────────────────────────────────────── #
try:
response = self.llm.invoke(messages)
raw = response.content.strip()
logger.debug("PlanningAgent raw response: %s", raw[:300])
except Exception as e:
logger.error("PlanningAgent LLM call failed: %s", e)
raise RuntimeError(f"Planning agent LLM call failed: {e}") from e
# ── Track token usage ─────────────────────────────────────── #
from observability.langsmith_tracer import extract_token_usage_from_response
prompt_t, completion_t = extract_token_usage_from_response(response)
updated_token_usage = state.token_usage.model_copy()
updated_token_usage.add(prompt_t, completion_t)
# ── Parse JSON ────────────────────────────────────────────── #
parsed = _parse_json(raw, agent="PlanningAgent")
# ── Build typed plan steps ────────────────────────────────── #
plan: list[PlanStep] = []
for raw_step in parsed.get("steps", []):
try:
plan.append(PlanStep(**raw_step))
except (ValidationError, TypeError) as e:
logger.warning("Skipping malformed plan step: %s β€” %s", raw_step, e)
if not plan:
raise ValueError("PlanningAgent returned no valid plan steps.")
logger.info("PlanningAgent produced %d steps", len(plan))
# ── Build SQL schema (SQL tasks only) ─────────────────────── #
sql_schema: SQLSchema | None = None
if state.language == Language.SQL and "schema" in parsed:
try:
sql_schema = SQLSchema(**parsed["schema"])
logger.info(
"PlanningAgent inferred schema with %d table(s)",
len(sql_schema.create_statements),
)
except (ValidationError, TypeError) as e:
logger.warning("SQL schema parsing failed: %s", e)
return {
"plan": plan,
"sql_schema": sql_schema,
"status": PipelineStatus.PLANNING,
"token_usage": updated_token_usage,
}
# ------------------------------------------------------------------ #
# Helpers #
# ------------------------------------------------------------------ #
def _parse_json(raw: str, agent: str) -> dict:
"""
Safely parse a JSON string returned by the LLM.
LLMs occasionally wrap JSON in markdown code fences (```json ... ```)
even when instructed not to. This function strips fences before
attempting to parse.
Args:
raw: Raw string content from the LLM response.
agent: Name of the calling agent, used in error messages.
Returns:
Parsed dict from the JSON response.
Raises:
ValueError: If the string cannot be parsed as valid JSON.
"""
# Strip common markdown fences the LLM might add despite instructions
cleaned = raw
if cleaned.startswith("```"):
# Remove opening fence (```json or ```)
cleaned = cleaned.split("\n", 1)[-1]
if cleaned.endswith("```"):
cleaned = cleaned.rsplit("```", 1)[0]
cleaned = cleaned.strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError as e:
logger.error("%s could not parse JSON: %s\nRaw output: %s", agent, e, raw[:500])
raise ValueError(
f"{agent} returned invalid JSON. "
f"Parse error: {e}. Raw output: {raw[:200]}"
) from e