draft-board 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
"""UDAR Planner Service - Understand-Decide-Act-Validate-Review architecture.
|
|
2
|
+
|
|
3
|
+
This service implements a lean, cost-optimized agent for ticket generation
|
|
4
|
+
and incremental replanning using LangGraph.
|
|
5
|
+
|
|
6
|
+
Key Cost Optimizations:
|
|
7
|
+
- Understand phase: Deterministic (0 LLM calls)
|
|
8
|
+
- Decide phase: Single batched LLM call (1 LLM call)
|
|
9
|
+
- Act phase: Deterministic (0 LLM calls)
|
|
10
|
+
- Validate phase: Mostly deterministic (0-1 LLM calls, optional)
|
|
11
|
+
- Review phase: Deterministic (0 LLM calls)
|
|
12
|
+
|
|
13
|
+
Total: 1-2 LLM calls per goal for initial generation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TypedDict
|
|
20
|
+
|
|
21
|
+
from langgraph.graph import END, StateGraph
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
23
|
+
|
|
24
|
+
from app.models.goal import Goal
|
|
25
|
+
from app.models.ticket import Ticket
|
|
26
|
+
from app.services.agent_memory_service import AgentMemoryService
|
|
27
|
+
from app.services.context_gatherer import ContextGatherer
|
|
28
|
+
from app.services.langchain_adapter import LangChainLLMAdapter
|
|
29
|
+
from app.services.llm_service import LLMService
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UDARState(TypedDict):
|
|
33
|
+
"""State for UDAR agent workflow.
|
|
34
|
+
|
|
35
|
+
This state is passed through all phases of the UDAR cycle.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Inputs
|
|
39
|
+
goal_id: str
|
|
40
|
+
goal_title: str
|
|
41
|
+
goal_description: str
|
|
42
|
+
repo_root: str
|
|
43
|
+
trigger: str # "initial_generation" | "post_completion" | "manual"
|
|
44
|
+
|
|
45
|
+
# Context (Understand phase - deterministic)
|
|
46
|
+
codebase_summary: str | None
|
|
47
|
+
existing_tickets: list[dict]
|
|
48
|
+
existing_ticket_count: int
|
|
49
|
+
project_type: str | None
|
|
50
|
+
|
|
51
|
+
# Decisions (Decide phase - 1 LLM call)
|
|
52
|
+
proposed_tickets: list[dict]
|
|
53
|
+
reasoning: str
|
|
54
|
+
should_generate_new: bool
|
|
55
|
+
llm_calls_made: int # Track LLM usage
|
|
56
|
+
|
|
57
|
+
# Validation (Validate phase - deterministic or 0-1 LLM call)
|
|
58
|
+
validated_tickets: list[dict]
|
|
59
|
+
validation_results: list[dict]
|
|
60
|
+
|
|
61
|
+
# Review (Review phase - deterministic)
|
|
62
|
+
final_tickets: list[dict]
|
|
63
|
+
review_summary: str
|
|
64
|
+
|
|
65
|
+
# Metadata
|
|
66
|
+
phase: str
|
|
67
|
+
iteration: int
|
|
68
|
+
errors: list[str]
|
|
69
|
+
total_input_tokens: int
|
|
70
|
+
total_output_tokens: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UDARPlannerService:
|
|
74
|
+
"""UDAR agent service for lean, adaptive ticket generation.
|
|
75
|
+
|
|
76
|
+
This service orchestrates the UDAR (Understand-Decide-Act-Validate-Review)
|
|
77
|
+
workflow using LangGraph, with a focus on minimizing LLM calls.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
service = UDARPlannerService(db)
|
|
81
|
+
result = await service.generate_from_goal(goal_id="goal-123")
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, db: AsyncSession):
|
|
85
|
+
"""Initialize UDAR planner service.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
db: Async database session
|
|
89
|
+
"""
|
|
90
|
+
self.db = db
|
|
91
|
+
self.llm_service = LLMService()
|
|
92
|
+
self.llm_adapter = LangChainLLMAdapter(llm_service=self.llm_service)
|
|
93
|
+
self.memory_service = AgentMemoryService(db)
|
|
94
|
+
|
|
95
|
+
# Build LangGraph workflow
|
|
96
|
+
self.agent = self._build_workflow()
|
|
97
|
+
|
|
98
|
+
def _build_workflow(self) -> StateGraph:
|
|
99
|
+
"""Build the LangGraph state machine for UDAR.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Compiled LangGraph agent
|
|
103
|
+
"""
|
|
104
|
+
workflow = StateGraph(UDARState)
|
|
105
|
+
|
|
106
|
+
# Add nodes for each phase
|
|
107
|
+
workflow.add_node("understand", self._understand_node)
|
|
108
|
+
workflow.add_node("decide", self._decide_node)
|
|
109
|
+
workflow.add_node("act", self._act_node)
|
|
110
|
+
workflow.add_node("validate", self._validate_node)
|
|
111
|
+
workflow.add_node("review", self._review_node)
|
|
112
|
+
|
|
113
|
+
# Define edges
|
|
114
|
+
workflow.set_entry_point("understand")
|
|
115
|
+
workflow.add_edge("understand", "decide")
|
|
116
|
+
workflow.add_edge("decide", "act")
|
|
117
|
+
workflow.add_edge("act", "validate")
|
|
118
|
+
workflow.add_conditional_edges(
|
|
119
|
+
"validate",
|
|
120
|
+
self._should_retry,
|
|
121
|
+
{
|
|
122
|
+
"retry": "decide", # Self-correction loop (max 1 iteration)
|
|
123
|
+
"proceed": "review",
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
workflow.add_edge("review", END)
|
|
127
|
+
|
|
128
|
+
return workflow.compile()
|
|
129
|
+
|
|
130
|
+
async def _understand_node(self, state: UDARState) -> UDARState:
|
|
131
|
+
"""Understand phase: Gather context deterministically (0 LLM calls).
|
|
132
|
+
|
|
133
|
+
This phase collects:
|
|
134
|
+
- Codebase structure via ContextGatherer
|
|
135
|
+
- Existing tickets from database
|
|
136
|
+
- Goal details
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
state: Current UDAR state
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Updated state with context
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
# Mark phase
|
|
146
|
+
state["phase"] = "understand"
|
|
147
|
+
|
|
148
|
+
# Gather codebase context (deterministic, cached)
|
|
149
|
+
gatherer = ContextGatherer(repo_path=Path(state["repo_root"]))
|
|
150
|
+
context = gatherer.gather(
|
|
151
|
+
include_readme=True,
|
|
152
|
+
include_todos=True,
|
|
153
|
+
max_files=1000,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Query existing tickets (deterministic)
|
|
157
|
+
from sqlalchemy import select
|
|
158
|
+
|
|
159
|
+
stmt = select(Ticket).where(Ticket.goal_id == state["goal_id"])
|
|
160
|
+
result = await self.db.execute(stmt)
|
|
161
|
+
tickets = result.scalars().all()
|
|
162
|
+
|
|
163
|
+
# Build context summary (deterministic)
|
|
164
|
+
state["codebase_summary"] = context.to_prompt_string()
|
|
165
|
+
state["project_type"] = context.project_type
|
|
166
|
+
state["existing_tickets"] = [
|
|
167
|
+
{
|
|
168
|
+
"id": t.id,
|
|
169
|
+
"title": t.title,
|
|
170
|
+
"state": t.state,
|
|
171
|
+
"priority": t.priority,
|
|
172
|
+
}
|
|
173
|
+
for t in tickets
|
|
174
|
+
]
|
|
175
|
+
state["existing_ticket_count"] = len(tickets)
|
|
176
|
+
|
|
177
|
+
# Log understanding phase
|
|
178
|
+
await self._log_phase(
|
|
179
|
+
state,
|
|
180
|
+
"understanding",
|
|
181
|
+
{
|
|
182
|
+
"project_type": context.project_type,
|
|
183
|
+
"file_count": len(context.file_tree),
|
|
184
|
+
"existing_ticket_count": len(tickets),
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
state["errors"].append(f"Understand phase error: {str(e)}")
|
|
190
|
+
|
|
191
|
+
return state
|
|
192
|
+
|
|
193
|
+
async def _decide_node(self, state: UDARState) -> UDARState:
|
|
194
|
+
"""Decide phase: Call LLM to generate ticket proposals (1 LLM call).
|
|
195
|
+
|
|
196
|
+
This is the PRIMARY LLM call in the UDAR workflow. It generates
|
|
197
|
+
ALL tickets in a single batched call.
|
|
198
|
+
|
|
199
|
+
If this is a retry iteration (iteration > 0), incorporates validation
|
|
200
|
+
feedback from the previous attempt to self-correct.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
state: Current UDAR state
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Updated state with proposed tickets
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
state["phase"] = "decide"
|
|
210
|
+
|
|
211
|
+
# Build prompt with context (includes validation feedback if retry)
|
|
212
|
+
prompt = self._build_decide_prompt(state)
|
|
213
|
+
|
|
214
|
+
# SINGLE LLM CALL for all tickets
|
|
215
|
+
response = await self.llm_adapter._acall(prompt)
|
|
216
|
+
state["llm_calls_made"] = state.get("llm_calls_made", 0) + 1
|
|
217
|
+
|
|
218
|
+
# Parse LLM response
|
|
219
|
+
parsed = self._parse_llm_response(response)
|
|
220
|
+
state["proposed_tickets"] = parsed["tickets"]
|
|
221
|
+
state["reasoning"] = parsed["reasoning"]
|
|
222
|
+
state["should_generate_new"] = len(parsed["tickets"]) > 0
|
|
223
|
+
|
|
224
|
+
# Log decision phase
|
|
225
|
+
await self._log_phase(
|
|
226
|
+
state,
|
|
227
|
+
"decision",
|
|
228
|
+
{
|
|
229
|
+
"tickets_proposed": len(state["proposed_tickets"]),
|
|
230
|
+
"reasoning_length": len(state["reasoning"]),
|
|
231
|
+
"llm_calls": 1,
|
|
232
|
+
"is_retry": state["iteration"] > 0,
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
state["errors"].append(f"Decide phase error: {str(e)}")
|
|
238
|
+
state["proposed_tickets"] = []
|
|
239
|
+
|
|
240
|
+
return state
|
|
241
|
+
|
|
242
|
+
async def _act_node(self, state: UDARState) -> UDARState:
|
|
243
|
+
"""Act phase: Format tickets deterministically (0 LLM calls).
|
|
244
|
+
|
|
245
|
+
This phase converts LLM proposals into database-ready schemas
|
|
246
|
+
using deterministic logic.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
state: Current UDAR state
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Updated state with formatted tickets
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
state["phase"] = "act"
|
|
256
|
+
|
|
257
|
+
# Format each ticket (deterministic)
|
|
258
|
+
formatted_tickets = []
|
|
259
|
+
for ticket_proposal in state["proposed_tickets"]:
|
|
260
|
+
formatted = self._format_ticket_proposal(ticket_proposal, state)
|
|
261
|
+
formatted_tickets.append(formatted)
|
|
262
|
+
|
|
263
|
+
state["proposed_tickets"] = formatted_tickets
|
|
264
|
+
|
|
265
|
+
# Log act phase
|
|
266
|
+
await self._log_phase(
|
|
267
|
+
state,
|
|
268
|
+
"act",
|
|
269
|
+
{
|
|
270
|
+
"tickets_formatted": len(formatted_tickets),
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
state["errors"].append(f"Act phase error: {str(e)}")
|
|
276
|
+
|
|
277
|
+
return state
|
|
278
|
+
|
|
279
|
+
async def _validate_node(self, state: UDARState) -> UDARState:
|
|
280
|
+
"""Validate phase: Check proposals deterministically (0 LLM calls).
|
|
281
|
+
|
|
282
|
+
This phase uses deterministic validation:
|
|
283
|
+
- Duplicate detection (exact title match)
|
|
284
|
+
- Dependency validation (blocker exists)
|
|
285
|
+
- Schema validation (required fields)
|
|
286
|
+
|
|
287
|
+
Optional LLM validation is disabled by default to save quota.
|
|
288
|
+
|
|
289
|
+
If validation fails and this is a retry iteration, the feedback
|
|
290
|
+
from previous validation is available in state["validation_feedback"].
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
state: Current UDAR state
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Updated state with validation results
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
state["phase"] = "validate"
|
|
300
|
+
|
|
301
|
+
validated_tickets = []
|
|
302
|
+
validation_results = []
|
|
303
|
+
validation_feedback_messages = []
|
|
304
|
+
|
|
305
|
+
for ticket in state["proposed_tickets"]:
|
|
306
|
+
# Deterministic validation
|
|
307
|
+
is_valid, reason = await self._validate_ticket_deterministic(
|
|
308
|
+
ticket, state
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
validation_results.append(
|
|
312
|
+
{
|
|
313
|
+
"ticket_title": ticket["title"],
|
|
314
|
+
"is_valid": is_valid,
|
|
315
|
+
"reason": reason,
|
|
316
|
+
"llm_used": False,
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if is_valid:
|
|
321
|
+
validated_tickets.append(ticket)
|
|
322
|
+
else:
|
|
323
|
+
# Collect feedback for self-correction
|
|
324
|
+
validation_feedback_messages.append(
|
|
325
|
+
f"- '{ticket['title']}': {reason}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
state["validated_tickets"] = validated_tickets
|
|
329
|
+
state["validation_results"] = validation_results
|
|
330
|
+
|
|
331
|
+
# Build feedback for self-correction (if needed)
|
|
332
|
+
if validation_feedback_messages:
|
|
333
|
+
state["validation_feedback"] = (
|
|
334
|
+
"The following tickets failed validation:\n"
|
|
335
|
+
+ "\n".join(validation_feedback_messages)
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
state["validation_feedback"] = ""
|
|
339
|
+
|
|
340
|
+
# Log validation phase
|
|
341
|
+
await self._log_phase(
|
|
342
|
+
state,
|
|
343
|
+
"validation",
|
|
344
|
+
{
|
|
345
|
+
"tickets_validated": len(validated_tickets),
|
|
346
|
+
"tickets_rejected": len(state["proposed_tickets"])
|
|
347
|
+
- len(validated_tickets),
|
|
348
|
+
"has_failures": len(validation_feedback_messages) > 0,
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
except Exception as e:
|
|
353
|
+
state["errors"].append(f"Validate phase error: {str(e)}")
|
|
354
|
+
|
|
355
|
+
return state
|
|
356
|
+
|
|
357
|
+
async def _review_node(self, state: UDARState) -> UDARState:
|
|
358
|
+
"""Review phase: Create database records (0 LLM calls).
|
|
359
|
+
|
|
360
|
+
This phase commits validated tickets to the database and
|
|
361
|
+
stores reasoning in agent memory as a checkpoint.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
state: Current UDAR state
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Updated state with final tickets
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
state["phase"] = "review"
|
|
371
|
+
|
|
372
|
+
final_tickets = []
|
|
373
|
+
|
|
374
|
+
# Create ticket records
|
|
375
|
+
for ticket_data in state["validated_tickets"]:
|
|
376
|
+
ticket = Ticket(
|
|
377
|
+
goal_id=state["goal_id"],
|
|
378
|
+
title=ticket_data["title"],
|
|
379
|
+
description=ticket_data["description"],
|
|
380
|
+
state="proposed", # All start as PROPOSED
|
|
381
|
+
priority=ticket_data["priority"],
|
|
382
|
+
board_id=ticket_data.get("board_id"),
|
|
383
|
+
)
|
|
384
|
+
self.db.add(ticket)
|
|
385
|
+
final_tickets.append(ticket_data)
|
|
386
|
+
|
|
387
|
+
await self.db.commit()
|
|
388
|
+
|
|
389
|
+
state["final_tickets"] = final_tickets
|
|
390
|
+
state["review_summary"] = f"Created {len(final_tickets)} tickets"
|
|
391
|
+
|
|
392
|
+
# Save checkpoint to agent memory (compressed)
|
|
393
|
+
checkpoint_id = (
|
|
394
|
+
f"{state['goal_id']}-{state['trigger']}-{datetime.utcnow().isoformat()}"
|
|
395
|
+
)
|
|
396
|
+
await self.memory_service.save_checkpoint(
|
|
397
|
+
goal_id=state["goal_id"],
|
|
398
|
+
checkpoint_id=checkpoint_id,
|
|
399
|
+
state=state,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Log review phase
|
|
403
|
+
await self._log_phase(
|
|
404
|
+
state,
|
|
405
|
+
"review",
|
|
406
|
+
{
|
|
407
|
+
"tickets_created": len(final_tickets),
|
|
408
|
+
"total_llm_calls": state.get("llm_calls_made", 0),
|
|
409
|
+
"checkpoint_saved": True,
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
except Exception as e:
|
|
414
|
+
state["errors"].append(f"Review phase error: {str(e)}")
|
|
415
|
+
await self.db.rollback()
|
|
416
|
+
|
|
417
|
+
return state
|
|
418
|
+
|
|
419
|
+
def _should_retry(self, state: UDARState) -> str:
|
|
420
|
+
"""Conditional edge: Decide whether to retry validation.
|
|
421
|
+
|
|
422
|
+
Retries if validation failed AND iteration < max_self_correction_iterations.
|
|
423
|
+
Max iterations is configured in draft.yaml (default: 1).
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
state: Current UDAR state
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
"retry" or "proceed"
|
|
430
|
+
"""
|
|
431
|
+
from app.services.config_service import ConfigService
|
|
432
|
+
|
|
433
|
+
failed_count = sum(1 for r in state["validation_results"] if not r["is_valid"])
|
|
434
|
+
|
|
435
|
+
# Get max iterations from config
|
|
436
|
+
config = ConfigService().load_config()
|
|
437
|
+
max_iterations = config.planner_config.udar.max_self_correction_iterations
|
|
438
|
+
|
|
439
|
+
# Retry if validation failed and under iteration limit
|
|
440
|
+
if failed_count > 0 and state["iteration"] < max_iterations:
|
|
441
|
+
state["iteration"] += 1
|
|
442
|
+
return "retry"
|
|
443
|
+
|
|
444
|
+
return "proceed"
|
|
445
|
+
|
|
446
|
+
# Helper methods
|
|
447
|
+
|
|
448
|
+
def _build_decide_prompt(self, state: UDARState) -> str:
|
|
449
|
+
"""Build prompt for Decide phase LLM call.
|
|
450
|
+
|
|
451
|
+
If this is a retry iteration, incorporates validation feedback
|
|
452
|
+
to help the LLM self-correct.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
state: Current UDAR state
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
Prompt string for LLM
|
|
459
|
+
"""
|
|
460
|
+
# Check if this is a retry with validation feedback
|
|
461
|
+
validation_feedback = state.get("validation_feedback", "")
|
|
462
|
+
is_retry = state["iteration"] > 0
|
|
463
|
+
|
|
464
|
+
prompt = f"""You are a software project planner. Generate tickets for the following goal:
|
|
465
|
+
|
|
466
|
+
**Goal:** {state["goal_title"]}
|
|
467
|
+
**Description:** {state["goal_description"]}
|
|
468
|
+
|
|
469
|
+
**Codebase Context:**
|
|
470
|
+
{state["codebase_summary"][:2000]} # Cap context to save tokens
|
|
471
|
+
|
|
472
|
+
**Existing Tickets:**
|
|
473
|
+
{json.dumps(state["existing_tickets"][:10], indent=2)} # Cap at 10
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
# Add validation feedback if this is a retry
|
|
477
|
+
if is_retry and validation_feedback:
|
|
478
|
+
prompt += f"""
|
|
479
|
+
|
|
480
|
+
**IMPORTANT: Previous Attempt Failed Validation**
|
|
481
|
+
|
|
482
|
+
Your previous ticket proposals had these issues:
|
|
483
|
+
{validation_feedback}
|
|
484
|
+
|
|
485
|
+
Please revise the ticket proposals to address these validation failures:
|
|
486
|
+
- Avoid duplicate titles (check existing tickets)
|
|
487
|
+
- Ensure all required fields are present
|
|
488
|
+
- Use clear, specific titles (at least 5 characters)
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
prompt += """
|
|
492
|
+
Generate a list of tickets needed to achieve this goal. Each ticket should:
|
|
493
|
+
1. Have a clear, actionable title (minimum 5 characters)
|
|
494
|
+
2. Include a description with acceptance criteria
|
|
495
|
+
3. Specify priority (0-100, higher = more important)
|
|
496
|
+
4. Optionally specify "blocked_by" (title of blocking ticket)
|
|
497
|
+
|
|
498
|
+
Return JSON in this format:
|
|
499
|
+
{{
|
|
500
|
+
"reasoning": "Brief explanation of why these tickets are needed",
|
|
501
|
+
"tickets": [
|
|
502
|
+
{{
|
|
503
|
+
"title": "Implement authentication models",
|
|
504
|
+
"description": "Create User, Session models with SQLAlchemy...",
|
|
505
|
+
"priority": 90,
|
|
506
|
+
"blocked_by": null
|
|
507
|
+
}},
|
|
508
|
+
...
|
|
509
|
+
]
|
|
510
|
+
}}
|
|
511
|
+
"""
|
|
512
|
+
return prompt
|
|
513
|
+
|
|
514
|
+
def _parse_llm_response(self, response: str) -> dict:
|
|
515
|
+
"""Parse LLM JSON response."""
|
|
516
|
+
try:
|
|
517
|
+
# Try to extract JSON from response
|
|
518
|
+
if "```json" in response:
|
|
519
|
+
json_str = response.split("```json")[1].split("```")[0].strip()
|
|
520
|
+
elif "```" in response:
|
|
521
|
+
json_str = response.split("```")[1].split("```")[0].strip()
|
|
522
|
+
else:
|
|
523
|
+
json_str = response.strip()
|
|
524
|
+
|
|
525
|
+
parsed = json.loads(json_str)
|
|
526
|
+
return parsed
|
|
527
|
+
|
|
528
|
+
except Exception:
|
|
529
|
+
# Fallback if parsing fails
|
|
530
|
+
return {
|
|
531
|
+
"reasoning": "Failed to parse LLM response",
|
|
532
|
+
"tickets": [],
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
def _format_ticket_proposal(self, proposal: dict, state: UDARState) -> dict:
|
|
536
|
+
"""Format ticket proposal with defaults."""
|
|
537
|
+
return {
|
|
538
|
+
"title": proposal.get("title", "Untitled"),
|
|
539
|
+
"description": proposal.get("description", ""),
|
|
540
|
+
"priority": proposal.get("priority", 50),
|
|
541
|
+
"blocked_by": proposal.get("blocked_by"),
|
|
542
|
+
"board_id": state.get("board_id"),
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async def _validate_ticket_deterministic(
|
|
546
|
+
self,
|
|
547
|
+
ticket: dict,
|
|
548
|
+
state: UDARState,
|
|
549
|
+
) -> tuple[bool, str]:
|
|
550
|
+
"""Validate ticket using deterministic checks (no LLM)."""
|
|
551
|
+
# Check for duplicates (exact title match)
|
|
552
|
+
for existing in state["existing_tickets"]:
|
|
553
|
+
if existing["title"].lower() == ticket["title"].lower():
|
|
554
|
+
return False, f"Duplicate of existing ticket: {existing['id']}"
|
|
555
|
+
|
|
556
|
+
# Check required fields
|
|
557
|
+
if not ticket.get("title"):
|
|
558
|
+
return False, "Missing title"
|
|
559
|
+
|
|
560
|
+
if len(ticket["title"]) < 5:
|
|
561
|
+
return False, "Title too short"
|
|
562
|
+
|
|
563
|
+
# All checks passed
|
|
564
|
+
return True, "Valid"
|
|
565
|
+
|
|
566
|
+
async def _log_phase(self, state: UDARState, phase: str, metadata: dict):
|
|
567
|
+
"""Log UDAR phase as TicketEvent.
|
|
568
|
+
|
|
569
|
+
For ticket generation (no job_id), we log agent activity as events
|
|
570
|
+
attached to the goal. This provides an audit trail of agent reasoning.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
state: Current UDAR state
|
|
574
|
+
phase: Phase name (understanding, decision, validation, etc.)
|
|
575
|
+
metadata: Phase-specific data to log
|
|
576
|
+
"""
|
|
577
|
+
from app.models.ticket_event import TicketEvent
|
|
578
|
+
|
|
579
|
+
# Create event describing agent activity
|
|
580
|
+
event = TicketEvent(
|
|
581
|
+
goal_id=state["goal_id"],
|
|
582
|
+
ticket_id=None, # Not ticket-specific yet
|
|
583
|
+
event_type="comment", # Use comment type for agent logs
|
|
584
|
+
actor_type="agent",
|
|
585
|
+
payload={
|
|
586
|
+
"agent_phase": phase,
|
|
587
|
+
"metadata": metadata,
|
|
588
|
+
"trigger": state.get("trigger", "unknown"),
|
|
589
|
+
},
|
|
590
|
+
)
|
|
591
|
+
self.db.add(event)
|
|
592
|
+
# Note: Commit happens at end of workflow, not per-phase
|
|
593
|
+
|
|
594
|
+
# Incremental Replanning (Phase 3)
|
|
595
|
+
|
|
596
|
+
async def replan_after_completion(self, ticket_ids: list[str]) -> dict:
|
|
597
|
+
"""Analyze completed tickets IN BATCH and generate follow-ups if needed.
|
|
598
|
+
|
|
599
|
+
COST OPTIMIZATION: Batches multiple tickets into single LLM call.
|
|
600
|
+
Only calls LLM if changes are significant (>10 files OR verification failed).
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
ticket_ids: List of ticket IDs to analyze (batch)
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Dict with replanning results:
|
|
607
|
+
{
|
|
608
|
+
"tickets_analyzed": 5,
|
|
609
|
+
"significant_tickets": 2,
|
|
610
|
+
"follow_ups_created": 1,
|
|
611
|
+
"llm_calls_made": 1,
|
|
612
|
+
"summary": "..."
|
|
613
|
+
}
|
|
614
|
+
"""
|
|
615
|
+
if not ticket_ids:
|
|
616
|
+
return {
|
|
617
|
+
"tickets_analyzed": 0,
|
|
618
|
+
"significant_tickets": 0,
|
|
619
|
+
"follow_ups_created": 0,
|
|
620
|
+
"llm_calls_made": 0,
|
|
621
|
+
"summary": "No tickets to analyze",
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
# Step 1: Gather context for ALL tickets (deterministic, 0 LLM calls)
|
|
625
|
+
from app.services.agent_tools import analyze_ticket_changes
|
|
626
|
+
|
|
627
|
+
tickets_context = []
|
|
628
|
+
for ticket_id in ticket_ids:
|
|
629
|
+
change_analysis = await analyze_ticket_changes.ainvoke(
|
|
630
|
+
{
|
|
631
|
+
"db": self.db,
|
|
632
|
+
"ticket_id": ticket_id,
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Parse JSON response
|
|
637
|
+
import json
|
|
638
|
+
|
|
639
|
+
parsed = json.loads(change_analysis)
|
|
640
|
+
|
|
641
|
+
if "error" not in parsed:
|
|
642
|
+
tickets_context.append(parsed)
|
|
643
|
+
|
|
644
|
+
if not tickets_context:
|
|
645
|
+
return {
|
|
646
|
+
"tickets_analyzed": len(ticket_ids),
|
|
647
|
+
"significant_tickets": 0,
|
|
648
|
+
"follow_ups_created": 0,
|
|
649
|
+
"llm_calls_made": 0,
|
|
650
|
+
"summary": "No valid tickets to analyze",
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
# Step 2: Apply deterministic filters (avoid LLM if possible)
|
|
654
|
+
# Only consider "significant" changes based on config threshold
|
|
655
|
+
from app.services.config_service import ConfigService
|
|
656
|
+
|
|
657
|
+
config = ConfigService().load_config()
|
|
658
|
+
significance_threshold = (
|
|
659
|
+
config.planner_config.udar.replan_significance_threshold
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
significant_tickets = [
|
|
663
|
+
t
|
|
664
|
+
for t in tickets_context
|
|
665
|
+
if t["file_count"] > significance_threshold or not t["verification_passed"]
|
|
666
|
+
]
|
|
667
|
+
|
|
668
|
+
if not significant_tickets:
|
|
669
|
+
# Changes are too minor, skip LLM entirely
|
|
670
|
+
return {
|
|
671
|
+
"tickets_analyzed": len(tickets_context),
|
|
672
|
+
"significant_tickets": 0,
|
|
673
|
+
"follow_ups_created": 0,
|
|
674
|
+
"llm_calls_made": 0,
|
|
675
|
+
"summary": f"Analyzed {len(tickets_context)} tickets, all changes minor (<10 files, verification passed)",
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
# Step 3: Only call LLM for significant changes
|
|
679
|
+
# Build prompt for batched analysis
|
|
680
|
+
prompt = self._build_replan_prompt(significant_tickets)
|
|
681
|
+
|
|
682
|
+
# SINGLE batched LLM call for all significant tickets
|
|
683
|
+
response = await self.llm_adapter._acall(prompt)
|
|
684
|
+
llm_calls_made = 1
|
|
685
|
+
|
|
686
|
+
# Parse LLM response
|
|
687
|
+
parsed_response = self._parse_llm_response(response)
|
|
688
|
+
follow_ups = parsed_response.get("tickets", [])
|
|
689
|
+
|
|
690
|
+
# Step 4: Create follow-up tickets (deterministic)
|
|
691
|
+
from sqlalchemy import select as sa_select
|
|
692
|
+
|
|
693
|
+
created_count = 0
|
|
694
|
+
for follow_up_data in follow_ups:
|
|
695
|
+
# Get goal_id from first significant ticket
|
|
696
|
+
first_ticket_id = significant_tickets[0]["ticket_id"]
|
|
697
|
+
stmt = sa_select(Ticket).where(Ticket.id == first_ticket_id)
|
|
698
|
+
result = await self.db.execute(stmt)
|
|
699
|
+
original_ticket = result.scalar_one_or_none()
|
|
700
|
+
|
|
701
|
+
if original_ticket:
|
|
702
|
+
follow_up = Ticket(
|
|
703
|
+
goal_id=original_ticket.goal_id,
|
|
704
|
+
board_id=original_ticket.board_id,
|
|
705
|
+
title=follow_up_data.get("title", "Follow-up ticket"),
|
|
706
|
+
description=follow_up_data.get("description", ""),
|
|
707
|
+
state="proposed",
|
|
708
|
+
priority=follow_up_data.get("priority", 50),
|
|
709
|
+
)
|
|
710
|
+
self.db.add(follow_up)
|
|
711
|
+
created_count += 1
|
|
712
|
+
|
|
713
|
+
await self.db.commit()
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
"tickets_analyzed": len(tickets_context),
|
|
717
|
+
"significant_tickets": len(significant_tickets),
|
|
718
|
+
"follow_ups_created": created_count,
|
|
719
|
+
"llm_calls_made": llm_calls_made,
|
|
720
|
+
"summary": f"Analyzed {len(tickets_context)} tickets, {len(significant_tickets)} significant, created {created_count} follow-ups",
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
def _build_replan_prompt(self, significant_tickets: list[dict]) -> str:
|
|
724
|
+
"""Build prompt for batched replanning LLM call.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
significant_tickets: List of ticket context dicts
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
Prompt string for LLM
|
|
731
|
+
"""
|
|
732
|
+
tickets_summary = "\n\n".join(
|
|
733
|
+
[
|
|
734
|
+
f"**Ticket {i + 1}: {t['ticket_title']}**\n"
|
|
735
|
+
f"- State: {t['state']}\n"
|
|
736
|
+
f"- Files changed: {t['file_count']}\n"
|
|
737
|
+
f"- Files: {', '.join(t['files_changed'][:5])}\n"
|
|
738
|
+
f"- Verification: {'✓ Passed' if t['verification_passed'] else '✗ Failed'}"
|
|
739
|
+
for i, t in enumerate(significant_tickets)
|
|
740
|
+
]
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
prompt = f"""You are analyzing completed tickets to determine if follow-up work is needed.
|
|
744
|
+
|
|
745
|
+
**Completed Tickets:**
|
|
746
|
+
{tickets_summary}
|
|
747
|
+
|
|
748
|
+
Analyze these tickets and determine if any follow-up tickets are needed. Common reasons for follow-ups:
|
|
749
|
+
1. Verification failed - need debugging/fixing
|
|
750
|
+
2. Large changes (>10 files) - may need tests, documentation, or refactoring
|
|
751
|
+
3. Core functionality added - may need integration with other parts
|
|
752
|
+
|
|
753
|
+
Only generate follow-ups if there's a clear, actionable need. Don't generate follow-ups for:
|
|
754
|
+
- Minor changes that are complete
|
|
755
|
+
- Tickets that already have tests
|
|
756
|
+
- Changes that are self-contained
|
|
757
|
+
|
|
758
|
+
Return JSON in this format:
|
|
759
|
+
{{
|
|
760
|
+
"reasoning": "Brief explanation of analysis",
|
|
761
|
+
"tickets": [
|
|
762
|
+
{{
|
|
763
|
+
"title": "Add tests for new authentication",
|
|
764
|
+
"description": "...",
|
|
765
|
+
"priority": 70
|
|
766
|
+
}}
|
|
767
|
+
]
|
|
768
|
+
}}
|
|
769
|
+
|
|
770
|
+
If no follow-ups are needed, return {{"reasoning": "...", "tickets": []}}
|
|
771
|
+
"""
|
|
772
|
+
return prompt
|
|
773
|
+
|
|
774
|
+
# Public API
|
|
775
|
+
|
|
776
|
+
async def generate_from_goal(
|
|
777
|
+
self,
|
|
778
|
+
goal_id: str,
|
|
779
|
+
fallback_to_legacy: bool = True,
|
|
780
|
+
timeout_seconds: int = 120,
|
|
781
|
+
) -> dict:
|
|
782
|
+
"""Generate tickets for a goal using UDAR agent (Phase 5: Production Hardened).
|
|
783
|
+
|
|
784
|
+
This is the main entry point for initial ticket generation with comprehensive
|
|
785
|
+
error handling, cost tracking, and graceful fallback to legacy mode.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
goal_id: Goal ID to generate tickets for
|
|
789
|
+
fallback_to_legacy: If True, falls back to legacy on errors (default: True)
|
|
790
|
+
timeout_seconds: Timeout for UDAR execution (default: 120s)
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
Dict with generated tickets and metadata:
|
|
794
|
+
{
|
|
795
|
+
"tickets": [...],
|
|
796
|
+
"summary": "Created 5 tickets",
|
|
797
|
+
"llm_calls_made": 1,
|
|
798
|
+
"phases_completed": ["understand", "decide", "act", "validate", "review"],
|
|
799
|
+
"used_legacy_fallback": false,
|
|
800
|
+
"cost_tracking": {...}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
Raises:
|
|
804
|
+
ResourceNotFoundError: If goal not found
|
|
805
|
+
UDARAgentError: If UDAR fails and fallback disabled
|
|
806
|
+
"""
|
|
807
|
+
import asyncio
|
|
808
|
+
import logging
|
|
809
|
+
|
|
810
|
+
from app.exceptions import (
|
|
811
|
+
LLMTimeoutError,
|
|
812
|
+
ResourceNotFoundError,
|
|
813
|
+
ToolExecutionError,
|
|
814
|
+
UDARAgentError,
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
logger = logging.getLogger(__name__)
|
|
818
|
+
|
|
819
|
+
# Get goal
|
|
820
|
+
goal = await self.db.get(Goal, goal_id)
|
|
821
|
+
if not goal:
|
|
822
|
+
raise ResourceNotFoundError("Goal", goal_id)
|
|
823
|
+
|
|
824
|
+
# Initialize state
|
|
825
|
+
initial_state: UDARState = {
|
|
826
|
+
"goal_id": goal_id,
|
|
827
|
+
"goal_title": goal.title,
|
|
828
|
+
"goal_description": goal.description or "",
|
|
829
|
+
"repo_root": goal.board.repo_root if goal.board else ".",
|
|
830
|
+
"trigger": "initial_generation",
|
|
831
|
+
"codebase_summary": None,
|
|
832
|
+
"existing_tickets": [],
|
|
833
|
+
"existing_ticket_count": 0,
|
|
834
|
+
"project_type": None,
|
|
835
|
+
"proposed_tickets": [],
|
|
836
|
+
"reasoning": "",
|
|
837
|
+
"should_generate_new": False,
|
|
838
|
+
"llm_calls_made": 0,
|
|
839
|
+
"validated_tickets": [],
|
|
840
|
+
"validation_results": [],
|
|
841
|
+
"final_tickets": [],
|
|
842
|
+
"review_summary": "",
|
|
843
|
+
"phase": "init",
|
|
844
|
+
"iteration": 0,
|
|
845
|
+
"errors": [],
|
|
846
|
+
"total_input_tokens": 0,
|
|
847
|
+
"total_output_tokens": 0,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
# Try UDAR agent with comprehensive error handling
|
|
851
|
+
try:
|
|
852
|
+
# Run agent with timeout
|
|
853
|
+
result_state = await asyncio.wait_for(
|
|
854
|
+
self.agent.ainvoke(initial_state),
|
|
855
|
+
timeout=timeout_seconds,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Track cost in AgentSession
|
|
859
|
+
await self._track_agent_session(goal_id, result_state)
|
|
860
|
+
|
|
861
|
+
# Return result
|
|
862
|
+
return {
|
|
863
|
+
"tickets": result_state["final_tickets"],
|
|
864
|
+
"summary": result_state["review_summary"],
|
|
865
|
+
"llm_calls_made": result_state["llm_calls_made"],
|
|
866
|
+
"phases_completed": [
|
|
867
|
+
"understand",
|
|
868
|
+
"decide",
|
|
869
|
+
"act",
|
|
870
|
+
"validate",
|
|
871
|
+
"review",
|
|
872
|
+
],
|
|
873
|
+
"errors": result_state["errors"],
|
|
874
|
+
"used_legacy_fallback": False,
|
|
875
|
+
"cost_tracking": {
|
|
876
|
+
"input_tokens": result_state["total_input_tokens"],
|
|
877
|
+
"output_tokens": result_state["total_output_tokens"],
|
|
878
|
+
},
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
except TimeoutError as e:
|
|
882
|
+
# LLM timeout - fallback to legacy if enabled
|
|
883
|
+
logger.warning(
|
|
884
|
+
f"UDAR agent timeout after {timeout_seconds}s for goal {goal_id}, "
|
|
885
|
+
f"falling back to legacy: {fallback_to_legacy}"
|
|
886
|
+
)
|
|
887
|
+
if fallback_to_legacy:
|
|
888
|
+
return await self._fallback_to_legacy(goal_id, reason="timeout")
|
|
889
|
+
raise LLMTimeoutError("UDAR", timeout_seconds) from e
|
|
890
|
+
|
|
891
|
+
except ToolExecutionError as e:
|
|
892
|
+
# Tool execution failed - try partial results or fallback
|
|
893
|
+
logger.error(
|
|
894
|
+
f"UDAR tool execution failed in {e.phase} phase for goal {goal_id}: {e}"
|
|
895
|
+
)
|
|
896
|
+
if fallback_to_legacy:
|
|
897
|
+
return await self._fallback_to_legacy(
|
|
898
|
+
goal_id, reason=f"tool_error:{e.tool_name}"
|
|
899
|
+
)
|
|
900
|
+
raise
|
|
901
|
+
|
|
902
|
+
except UDARAgentError as e:
|
|
903
|
+
# UDAR-specific error - fallback
|
|
904
|
+
logger.error(f"UDAR agent error in {e.phase} phase for goal {goal_id}: {e}")
|
|
905
|
+
if fallback_to_legacy:
|
|
906
|
+
return await self._fallback_to_legacy(
|
|
907
|
+
goal_id, reason=f"udar_error:{e.phase}"
|
|
908
|
+
)
|
|
909
|
+
raise
|
|
910
|
+
|
|
911
|
+
except Exception as e:
|
|
912
|
+
# Unexpected error - always try fallback
|
|
913
|
+
logger.exception(f"Unexpected UDAR agent error for goal {goal_id}: {e}")
|
|
914
|
+
if fallback_to_legacy:
|
|
915
|
+
return await self._fallback_to_legacy(
|
|
916
|
+
goal_id, reason="unexpected_error"
|
|
917
|
+
)
|
|
918
|
+
raise UDARAgentError(f"Unexpected error: {str(e)}") from e
|
|
919
|
+
|
|
920
|
+
# Phase 5: Production Hardening - Helper Methods
|
|
921
|
+
|
|
922
|
+
async def _fallback_to_legacy(self, goal_id: str, reason: str) -> dict:
|
|
923
|
+
"""Fallback to legacy ticket generation when UDAR fails.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
goal_id: Goal ID to generate tickets for
|
|
927
|
+
reason: Reason for fallback (for logging/telemetry)
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
Dict with legacy-generated tickets and metadata
|
|
931
|
+
"""
|
|
932
|
+
import logging
|
|
933
|
+
|
|
934
|
+
from app.services.ticket_generation_service import TicketGenerationService
|
|
935
|
+
|
|
936
|
+
logger = logging.getLogger(__name__)
|
|
937
|
+
|
|
938
|
+
logger.info(
|
|
939
|
+
f"Falling back to legacy ticket generation for goal {goal_id}, "
|
|
940
|
+
f"reason: {reason}"
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
# Use legacy service
|
|
944
|
+
legacy_service = TicketGenerationService(db=self.db)
|
|
945
|
+
result = await legacy_service.generate_from_goal(goal_id=goal_id)
|
|
946
|
+
|
|
947
|
+
# Wrap result in UDAR-compatible format
|
|
948
|
+
return {
|
|
949
|
+
"tickets": result.get("tickets", []),
|
|
950
|
+
"summary": f"Generated {len(result.get('tickets', []))} tickets (legacy fallback)",
|
|
951
|
+
"llm_calls_made": 1, # Legacy uses 1 LLM call
|
|
952
|
+
"phases_completed": ["legacy"],
|
|
953
|
+
"errors": [f"UDAR fallback: {reason}"],
|
|
954
|
+
"used_legacy_fallback": True,
|
|
955
|
+
"fallback_reason": reason,
|
|
956
|
+
"cost_tracking": {
|
|
957
|
+
"input_tokens": 0, # Legacy doesn't track tokens
|
|
958
|
+
"output_tokens": 0,
|
|
959
|
+
},
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async def _track_agent_session(self, goal_id: str, state: UDARState) -> None:
|
|
963
|
+
"""Track UDAR agent session costs in database.
|
|
964
|
+
|
|
965
|
+
Logs cost info for observability. AgentSession records require a ticket_id
|
|
966
|
+
(FK to tickets), so UDAR goal-level sessions are logged but not persisted
|
|
967
|
+
to the agent_sessions table.
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
goal_id: Goal ID
|
|
971
|
+
state: Final UDAR state with token counts
|
|
972
|
+
"""
|
|
973
|
+
import logging
|
|
974
|
+
|
|
975
|
+
from app.services.agent_registry import AGENT_REGISTRY, AgentType
|
|
976
|
+
|
|
977
|
+
logger = logging.getLogger(__name__)
|
|
978
|
+
|
|
979
|
+
try:
|
|
980
|
+
# Get agent pricing from registry
|
|
981
|
+
agent_config = AGENT_REGISTRY.get(AgentType.CLAUDE)
|
|
982
|
+
|
|
983
|
+
if not agent_config or not agent_config.cost_per_1k_input:
|
|
984
|
+
logger.warning(
|
|
985
|
+
"Claude agent config not found in registry, skipping cost tracking"
|
|
986
|
+
)
|
|
987
|
+
return
|
|
988
|
+
|
|
989
|
+
# Calculate cost
|
|
990
|
+
input_tokens = state.get("total_input_tokens", 0)
|
|
991
|
+
output_tokens = state.get("total_output_tokens", 0)
|
|
992
|
+
|
|
993
|
+
cost_usd = (input_tokens / 1000) * agent_config.cost_per_1k_input + (
|
|
994
|
+
output_tokens / 1000
|
|
995
|
+
) * (agent_config.cost_per_1k_output or 0)
|
|
996
|
+
|
|
997
|
+
logger.info(
|
|
998
|
+
f"UDAR agent session for goal {goal_id}: "
|
|
999
|
+
f"{input_tokens} input tokens, {output_tokens} output tokens, "
|
|
1000
|
+
f"${cost_usd:.4f} estimated cost, "
|
|
1001
|
+
f"phases={state.get('phase', 'unknown')}, "
|
|
1002
|
+
f"llm_calls={state.get('llm_calls_made', 0)}"
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
except Exception as e:
|
|
1006
|
+
# Don't fail request if cost tracking fails
|
|
1007
|
+
logger.error(f"Failed to track agent session cost for goal {goal_id}: {e}")
|