Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ frontend/dist/
*.log
.claude/settings.local.json
*.tsbuildinfo
tools/dotnet-reflector/bin/
tools/dotnet-reflector/obj/
2 changes: 1 addition & 1 deletion backend/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class Container(containers.DeclarativeContainer):
git_service = providers.Factory(GitService, db=db)

# --- Planning & Decomposition ---
planner = providers.Factory(PlannerService, db=db, budget=budget)
planner = providers.Factory(PlannerService, db=db, budget=budget, tool_registry=tool_registry)
decomposer = providers.Factory(DecomposerService, db=db)

# --- Executor (depends on all services) ---
Expand Down
57 changes: 56 additions & 1 deletion backend/services/claude_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,52 @@
logger = logging.getLogger("orchestration.executor")


def _extract_csharp_context(context: list[dict]) -> dict | None:
"""Extract C# method task context entries if present.

Returns a dict with target_signature, available_methods, constructor_params,
or None if this is not a C# method task.
"""
result = {}
for ctx in context:
ctx_type = ctx.get("type", "")
if ctx_type == "target_signature":
result["target_signature"] = ctx.get("content", "")
elif ctx_type == "available_methods":
result["available_methods"] = ctx.get("content", "")
elif ctx_type == "constructor_params":
result["constructor_params"] = ctx.get("content", "")
return result if "target_signature" in result else None


def _build_csharp_worker_prompt(csharp_ctx: dict, task_row: dict) -> str:
"""Build a structured WorkerInstruction prompt for C# method tasks."""
signature = csharp_ctx.get("target_signature", "")
available = csharp_ctx.get("available_methods", "None")
ctor_params = csharp_ctx.get("constructor_params", "None")
description = task_row.get("description", "")

return (
"<WorkerInstruction>\n"
" <Context>\n"
f" <TargetSignature>{signature}</TargetSignature>\n"
f" <InjectedDependencies>{ctor_params}</InjectedDependencies>\n"
f" <AvailableMethods>\n{available}\n </AvailableMethods>\n"
" </Context>\n\n"
f" <LogicGoal>\n{description}\n </LogicGoal>\n\n"
" <ExecutionRules>\n"
" <Rule>Output ONLY the method body. Do not wrap in a class or add using statements.</Rule>\n"
" <Rule>Use the AvailableMethods strictly. Do not hallucinate helper methods that don't exist.</Rule>\n"
" <Rule>If the LogicGoal requires a method that doesn't exist in AvailableMethods, "
"note it clearly in your output so the orchestrator can create it.</Rule>\n"
" <Rule>Keep the implementation under 50 lines. If more is needed, "
"split logic into private helpers and note them.</Rule>\n"
" </ExecutionRules>\n\n"
" <OutputConstraint>Return raw C# code only. No markdown formatting blocks.</OutputConstraint>\n"
"</WorkerInstruction>"
)


async def run_claude_task(
*,
task_row,
Expand Down Expand Up @@ -48,7 +94,14 @@ async def run_claude_task(

# Build context
context = json.loads(task_row["context_json"]) if task_row["context_json"] else []
system_parts = [task_row["system_prompt"] or "You are a focused task executor."]

# Check for C# method task — uses structured WorkerInstruction prompt
csharp_context = _extract_csharp_context(context)
if csharp_context:
system_parts = [_build_csharp_worker_prompt(csharp_context, task_row)]
else:
system_parts = [task_row["system_prompt"] or "You are a focused task executor."]

system_parts.append(
"\n<meta_instructions>\n"
"If you discover any constraints, gotchas, API quirks, or architectural "
Expand All @@ -58,6 +111,8 @@ async def run_claude_task(
)
for ctx in context:
ctx_type = ctx.get("type", "context")
if ctx_type in ("target_signature", "available_methods", "constructor_params"):
continue # Already injected into WorkerInstruction
system_parts.append(f"\n<{ctx_type}>\n{ctx.get('content', '')}\n</{ctx_type}>")

# Inject project knowledge from earlier tasks
Expand Down
99 changes: 99 additions & 0 deletions backend/services/decomposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ async def decompose(self, project_id: str, plan_id: str) -> dict:
if files:
context.append({"type": "affected_files", "content": ", ".join(files)})

# C# reflection context: inject typed contracts for method-level tasks
target_sig = task_def.get("target_signature")
if target_sig:
context.append({"type": "target_signature", "content": target_sig})
available_methods = task_def.get("available_methods")
if available_methods:
content = "\n".join(available_methods) if isinstance(available_methods, list) else str(available_methods)
context.append({"type": "available_methods", "content": content})
ctor_params = task_def.get("constructor_params")
if ctor_params:
content = ", ".join(ctor_params) if isinstance(ctor_params, list) else str(ctor_params)
context.append({"type": "constructor_params", "content": content})

# Requirement traceability
requirement_ids = task_def.get("requirement_ids", [])

Expand Down Expand Up @@ -181,6 +194,13 @@ async def decompose(self, project_id: str, plan_id: str) -> dict:
(task_ids[i], task_ids[dep_idx]),
))

# Auto-create assembly tasks for C# method phases
# Groups csharp_method tasks by target_class, creates one assembly task per class
_create_csharp_assembly_tasks(
tasks_data, task_ids, waves, phase_names,
project_id, plan_id, now, write_statements,
)

# Mark plan as approved
write_statements.append((
"UPDATE plans SET status = ? WHERE id = ?",
Expand Down Expand Up @@ -213,6 +233,85 @@ async def decompose_plan(project_id: str, plan_id: str, *, db) -> dict:
return await DecomposerService(db=db).decompose(project_id, plan_id)


def _create_csharp_assembly_tasks(
tasks_data, task_ids, waves, phase_names,
project_id, plan_id, now, write_statements,
):
"""Auto-create assembly tasks for C# method phases.

Groups csharp_method tasks by target_class. For each class, creates
an assembly task that depends on all its method tasks. The assembly
task stitches method bodies into the class file and runs dotnet build.
"""
from collections import defaultdict

# Group task indices by target_class
class_tasks: dict[str, list[int]] = defaultdict(list)
for i, task_def in enumerate(tasks_data):
if task_def.get("task_type") == "csharp_method" and task_def.get("target_class"):
class_tasks[task_def["target_class"]].append(i)

if not class_tasks:
return

for target_class, method_indices in class_tasks.items():
assembly_id = uuid.uuid4().hex[:12]
class_name = target_class.split(".")[-1]

# Collect affected files from method tasks
affected = set()
for idx in method_indices:
for f in tasks_data[idx].get("affected_files", []):
affected.add(f)

# Assembly wave = max wave of its methods + 1
assembly_wave = max(waves[i] for i in method_indices) + 1

description = (
f"Assemble method implementations for {target_class} into the class file. "
f"Stitch the method bodies from {len(method_indices)} completed tasks into "
f"the class shell, then run 'dotnet build' to verify compilation."
)

context = [
{"type": "task_description", "content": description},
{"type": "target_class", "content": target_class},
]
if affected:
context.append({"type": "affected_files", "content": ", ".join(sorted(affected))})

# Get the phase name from the first method task
phase = phase_names[method_indices[0]] if method_indices else None

write_statements.append((
"INSERT INTO tasks (id, project_id, plan_id, title, description, task_type, "
"priority, status, model_tier, context_json, tools_json, "
"max_tokens, wave, phase, requirement_ids_json, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(assembly_id, project_id, plan_id,
f"Assemble {class_name}", description, "csharp_assembly",
9999, TaskStatus.PENDING, "sonnet", # high priority number = runs last
json.dumps(context), json.dumps(["read_file", "write_file"]),
4096, assembly_wave, phase,
json.dumps([]), now, now),
))

# Add dependency edges: assembly depends on all its method tasks
for method_idx in method_indices:
write_statements.append((
"INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
(assembly_id, task_ids[method_idx]),
))

task_ids.append(assembly_id)
waves.append(assembly_wave)

logger.info(
"Created %d assembly tasks for C# classes: %s",
len(class_tasks), ", ".join(class_tasks.keys()),
)


def _check_for_cycles(tasks_data: list[dict]) -> None:
"""Detect dependency cycles in the task graph before creating rows.

Expand Down
146 changes: 143 additions & 3 deletions backend/services/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# Used by: routes/projects.py, container.py

import json
import logging
import time
import uuid

Expand All @@ -17,6 +18,8 @@
from backend.services.model_router import calculate_cost
from backend.utils.json_utils import extract_json_object, parse_requirements

logger = logging.getLogger("orchestration.planner")

# Token estimates for budget reservation before API calls
_EST_PLANNING_INPUT_TOKENS = 2000 # system prompt (~1.5k) + requirements
_EST_PLANNING_OUTPUT_TOKENS = 2000 # plan JSON response
Expand Down Expand Up @@ -196,12 +199,139 @@ def _build_system_prompt(rigor: PlanningRigor) -> str:
return _PLANNING_PREAMBLE + _RIGOR_SUFFIXES[rigor]


# ---------------------------------------------------------------------------
# C# Reflection-based decomposition strategy
# ---------------------------------------------------------------------------

_CSHARP_PLANNING_PREAMBLE = """You are a C# code architect for an AI orchestration engine. Your job is to decompose a feature request into method-level implementation tasks using reflected type metadata from the target assembly.

You will receive:
1. The feature requirements (numbered [R1], [R2], etc.)
2. A reflected type map showing existing classes, methods, properties, and constructors from the .NET assembly.

<strategy>
- Each task implements exactly ONE method body. The method signature is already defined.
- Tasks are organized into phases, one phase per class being modified or created.
- Each task receives: the target method signature, injected dependencies (constructor params), and available sibling methods.
- The AI worker will output ONLY the method body — no class wrapper, no using statements.
- A final assembly task per class stitches method bodies into the class file and runs dotnet build.
- Keep each method under 50 lines of logic. If a method needs more, split into private helpers and add those as separate tasks.
</strategy>

<rules>
- Use the reflected type map strictly. Do not invent classes or interfaces that don't exist.
- If a new class is needed, create a "scaffold" task that generates the class shell first.
- Map depends_on to the task indices (0-based, global across phases) of methods that must complete before this one.
- For new methods on existing classes, include the existing method signatures in available_methods.
- For methods that modify shared state, note potential concurrency concerns in the description.
</rules>

"""

_CSHARP_TASK_SCHEMA = """{
"title": "ClassName.MethodName",
"description": "What this method does, including behavioral contract and edge cases",
"task_type": "csharp_method",
"complexity": "simple|medium|complex",
"depends_on": [],
"target_class": "Namespace.ClassName",
"target_signature": "public async Task<bool> MethodName(ParamType param)",
"available_methods": ["signatures of other methods in the same class or injected services"],
"constructor_params": ["IDbContext db", "ILogger logger"],
"requirement_ids": ["R1"],
"verification_criteria": "How to verify this method works correctly",
"affected_files": ["src/Services/MyService.cs"]
}"""

_CSHARP_RIGOR_SUFFIX = f"""Produce a JSON plan organized into phases. Each phase corresponds to one class being modified or created.

{{
"summary": "Brief summary of the feature being implemented",
"phases": [
{{
"name": "ClassName (e.g. 'UserService', 'OrderValidator')",
"description": "What this class does and why these methods are needed",
"tasks": [
{_CSHARP_TASK_SCHEMA}
]
}}
],
"open_questions": [
{{
"question": "An ambiguity or decision in the requirements",
"proposed_answer": "How you propose to handle it",
"impact": "What changes if the answer differs"
}}
],
"assembly_config": {{
"new_files": ["Paths to new .cs files that need to be created"],
"modified_files": ["Paths to existing .cs files that will be modified"]
}}
}}

Phase guidelines:
- One phase per class. Phase name = class name.
- Within a phase, order tasks so independent methods come first.
- depends_on indices are GLOBAL across all phases (0-based from the first task in the first phase).
- After all method tasks in a phase, the system will auto-create an assembly task to stitch and build.

Open questions:
- Surface 1-5 ambiguities about the requirements or existing code structure.

Respond with ONLY the JSON plan, no markdown fences or explanation."""


def _build_csharp_system_prompt(type_map: str) -> str:
"""Build the system prompt for C# reflection-based planning."""
return (
_CSHARP_PLANNING_PREAMBLE
+ f"<reflected_types>\n{type_map}\n</reflected_types>\n\n"
+ _CSHARP_RIGOR_SUFFIX
)


class PlannerService:
"""Injectable service that generates plans from project requirements."""

def __init__(self, *, db, budget):
def __init__(self, *, db, budget, tool_registry=None):
self._db = db
self._budget = budget
self._tool_registry = tool_registry

async def _get_csharp_type_map(self, config: dict) -> str | None:
"""Run .NET reflection to get the type map for C# planning.

Reads assembly_path or csproj_path from project config.
Returns formatted type map string, or None if reflection fails/unavailable.
"""
assembly_path = config.get("assembly_path")
csproj_path = config.get("csproj_path")

if not assembly_path and not csproj_path:
logger.warning("csharp_reflection strategy requires assembly_path or csproj_path in config")
return None

try:
from backend.tools.dotnet_reflection import (
build_project,
format_type_map,
reflect_assembly,
)

# Build from csproj if needed
if csproj_path and not assembly_path:
success, result = await build_project(csproj_path)
if not success:
logger.warning("C# build failed: %s", result)
return None
assembly_path = result

ns_filter = config.get("namespace_filter")
data = await reflect_assembly(assembly_path, ns_filter)
return format_type_map(data)
except Exception as e:
logger.warning("C# reflection failed, falling back to generic planner: %s", e)
return None

async def generate(
self,
Expand Down Expand Up @@ -235,8 +365,18 @@ async def generate(
except ValueError:
rigor = PlanningRigor.L2

system_prompt = _build_system_prompt(rigor)
max_tokens = _MAX_TOKENS_BY_RIGOR[rigor]
# Check for C# reflection decomposition strategy
decomposition_strategy = config.get("decomposition_strategy")
csharp_type_map = None
if decomposition_strategy == "csharp_reflection":
csharp_type_map = await self._get_csharp_type_map(config)

if csharp_type_map is not None:
system_prompt = _build_csharp_system_prompt(csharp_type_map)
max_tokens = 8192 # C# plans are detailed
else:
system_prompt = _build_system_prompt(rigor)
max_tokens = _MAX_TOKENS_BY_RIGOR[rigor]

# Reserve budget before making the API call (prevents TOCTOU race)
estimated_cost = calculate_cost(PLANNING_MODEL, _EST_PLANNING_INPUT_TOKENS, _EST_PLANNING_OUTPUT_TOKENS)
Expand Down
Loading