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,223 @@
|
|
|
1
|
+
"""Agent memory service for UDAR conversation history.
|
|
2
|
+
|
|
3
|
+
This service manages compressed conversation checkpoints for the UDAR agent.
|
|
4
|
+
Instead of storing full LLM conversation history (expensive), it stores
|
|
5
|
+
only summaries and metadata (lean storage optimization).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import delete, select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
|
|
15
|
+
from app.models.agent_conversation_history import AgentConversationHistory
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentMemoryService:
|
|
19
|
+
"""Manages conversation history and checkpoints for UDAR agent.
|
|
20
|
+
|
|
21
|
+
COST OPTIMIZATION: Stores summaries, not full messages.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, db: AsyncSession):
|
|
25
|
+
"""Initialize memory service.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
db: Async database session
|
|
29
|
+
"""
|
|
30
|
+
self.db = db
|
|
31
|
+
|
|
32
|
+
async def save_checkpoint(
|
|
33
|
+
self,
|
|
34
|
+
goal_id: str,
|
|
35
|
+
checkpoint_id: str,
|
|
36
|
+
state: dict[str, Any],
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Save agent state to database (COMPRESSED).
|
|
39
|
+
|
|
40
|
+
Only stores summary + metadata, not full LLM responses.
|
|
41
|
+
This keeps storage lean while preserving essential context.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
goal_id: Goal this checkpoint belongs to
|
|
45
|
+
checkpoint_id: Unique checkpoint identifier
|
|
46
|
+
state: UDAR state dict to checkpoint
|
|
47
|
+
"""
|
|
48
|
+
# Extract only essential data (not full messages)
|
|
49
|
+
summary = {
|
|
50
|
+
"tickets_proposed": len(state.get("proposed_tickets", [])),
|
|
51
|
+
"tickets_validated": len(state.get("validated_tickets", [])),
|
|
52
|
+
"reasoning_summary": state.get("reasoning", "")[:500], # Cap at 500 chars
|
|
53
|
+
"phase": state.get("phase", "unknown"),
|
|
54
|
+
"iteration": state.get("iteration", 0),
|
|
55
|
+
"llm_calls_made": state.get("llm_calls_made", 0),
|
|
56
|
+
"trigger": state.get("trigger", "unknown"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Check if checkpoint already exists
|
|
60
|
+
existing = await self.db.execute(
|
|
61
|
+
select(AgentConversationHistory).where(
|
|
62
|
+
AgentConversationHistory.goal_id == goal_id,
|
|
63
|
+
AgentConversationHistory.checkpoint_id == checkpoint_id,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
existing_checkpoint = existing.scalar_one_or_none()
|
|
67
|
+
|
|
68
|
+
if existing_checkpoint:
|
|
69
|
+
# Update existing checkpoint
|
|
70
|
+
existing_checkpoint.metadata_json = json.dumps(summary)
|
|
71
|
+
existing_checkpoint.updated_at = datetime.utcnow()
|
|
72
|
+
else:
|
|
73
|
+
# Create new checkpoint
|
|
74
|
+
history = AgentConversationHistory(
|
|
75
|
+
goal_id=goal_id,
|
|
76
|
+
checkpoint_id=checkpoint_id,
|
|
77
|
+
messages_json=json.dumps([]), # Empty, don't store full messages
|
|
78
|
+
metadata_json=json.dumps(summary),
|
|
79
|
+
)
|
|
80
|
+
self.db.add(history)
|
|
81
|
+
|
|
82
|
+
await self.db.commit()
|
|
83
|
+
|
|
84
|
+
async def load_checkpoint(self, goal_id: str) -> dict[str, Any] | None:
|
|
85
|
+
"""Load most recent checkpoint summary (not full history).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
goal_id: Goal to load checkpoint for
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Checkpoint summary dict, or None if no checkpoint exists
|
|
92
|
+
"""
|
|
93
|
+
result = await self.db.execute(
|
|
94
|
+
select(AgentConversationHistory)
|
|
95
|
+
.where(AgentConversationHistory.goal_id == goal_id)
|
|
96
|
+
.order_by(AgentConversationHistory.created_at.desc())
|
|
97
|
+
.limit(1)
|
|
98
|
+
)
|
|
99
|
+
history = result.scalar_one_or_none()
|
|
100
|
+
|
|
101
|
+
if history:
|
|
102
|
+
# Return summary only, agent doesn't need full history
|
|
103
|
+
return json.loads(history.metadata_json)
|
|
104
|
+
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
async def list_checkpoints(
|
|
108
|
+
self,
|
|
109
|
+
goal_id: str,
|
|
110
|
+
limit: int = 10,
|
|
111
|
+
) -> list[dict[str, Any]]:
|
|
112
|
+
"""List recent checkpoints for a goal.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
goal_id: Goal to list checkpoints for
|
|
116
|
+
limit: Maximum number of checkpoints to return
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of checkpoint dicts with metadata
|
|
120
|
+
"""
|
|
121
|
+
result = await self.db.execute(
|
|
122
|
+
select(AgentConversationHistory)
|
|
123
|
+
.where(AgentConversationHistory.goal_id == goal_id)
|
|
124
|
+
.order_by(AgentConversationHistory.created_at.desc())
|
|
125
|
+
.limit(limit)
|
|
126
|
+
)
|
|
127
|
+
checkpoints = result.scalars().all()
|
|
128
|
+
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
"id": checkpoint.id,
|
|
132
|
+
"checkpoint_id": checkpoint.checkpoint_id,
|
|
133
|
+
"created_at": checkpoint.created_at.isoformat(),
|
|
134
|
+
"metadata": json.loads(checkpoint.metadata_json),
|
|
135
|
+
}
|
|
136
|
+
for checkpoint in checkpoints
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
async def cleanup_old_checkpoints(self, days: int = 30) -> int:
|
|
140
|
+
"""Delete checkpoints older than N days to save storage.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
days: Age threshold in days
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Number of checkpoints deleted
|
|
147
|
+
"""
|
|
148
|
+
cutoff = datetime.utcnow() - timedelta(days=days)
|
|
149
|
+
|
|
150
|
+
result = await self.db.execute(
|
|
151
|
+
delete(AgentConversationHistory).where(
|
|
152
|
+
AgentConversationHistory.created_at < cutoff
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
deleted_count = result.rowcount
|
|
156
|
+
|
|
157
|
+
await self.db.commit()
|
|
158
|
+
|
|
159
|
+
return deleted_count
|
|
160
|
+
|
|
161
|
+
async def delete_checkpoints_for_goal(self, goal_id: str) -> int:
|
|
162
|
+
"""Delete all checkpoints for a goal.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
goal_id: Goal to delete checkpoints for
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Number of checkpoints deleted
|
|
169
|
+
"""
|
|
170
|
+
result = await self.db.execute(
|
|
171
|
+
delete(AgentConversationHistory).where(
|
|
172
|
+
AgentConversationHistory.goal_id == goal_id
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
deleted_count = result.rowcount
|
|
176
|
+
|
|
177
|
+
await self.db.commit()
|
|
178
|
+
|
|
179
|
+
return deleted_count
|
|
180
|
+
|
|
181
|
+
async def get_goal_summary(self, goal_id: str) -> dict[str, Any]:
|
|
182
|
+
"""Get summary of agent activity for a goal.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
goal_id: Goal to summarize
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Summary dict with aggregated statistics
|
|
189
|
+
"""
|
|
190
|
+
result = await self.db.execute(
|
|
191
|
+
select(AgentConversationHistory).where(
|
|
192
|
+
AgentConversationHistory.goal_id == goal_id
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
checkpoints = result.scalars().all()
|
|
196
|
+
|
|
197
|
+
if not checkpoints:
|
|
198
|
+
return {
|
|
199
|
+
"goal_id": goal_id,
|
|
200
|
+
"checkpoint_count": 0,
|
|
201
|
+
"total_llm_calls": 0,
|
|
202
|
+
"total_tickets_proposed": 0,
|
|
203
|
+
"first_checkpoint": None,
|
|
204
|
+
"last_checkpoint": None,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Aggregate statistics
|
|
208
|
+
total_llm_calls = 0
|
|
209
|
+
total_tickets_proposed = 0
|
|
210
|
+
|
|
211
|
+
for checkpoint in checkpoints:
|
|
212
|
+
metadata = json.loads(checkpoint.metadata_json)
|
|
213
|
+
total_llm_calls += metadata.get("llm_calls_made", 0)
|
|
214
|
+
total_tickets_proposed += metadata.get("tickets_proposed", 0)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"goal_id": goal_id,
|
|
218
|
+
"checkpoint_count": len(checkpoints),
|
|
219
|
+
"total_llm_calls": total_llm_calls,
|
|
220
|
+
"total_tickets_proposed": total_tickets_proposed,
|
|
221
|
+
"first_checkpoint": checkpoints[-1].created_at.isoformat(),
|
|
222
|
+
"last_checkpoint": checkpoints[0].created_at.isoformat(),
|
|
223
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Agent registry for supporting multiple AI coding agents.
|
|
2
|
+
|
|
3
|
+
This module provides a pluggable architecture for supporting multiple
|
|
4
|
+
AI coding agents (Claude, Amp, Codex, Gemini, etc.) with a unified interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import shutil
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentType(StrEnum):
|
|
19
|
+
"""Supported AI coding agents."""
|
|
20
|
+
|
|
21
|
+
CLAUDE = "claude"
|
|
22
|
+
CURSOR = "cursor"
|
|
23
|
+
AMP = "amp"
|
|
24
|
+
CODEX = "codex"
|
|
25
|
+
GEMINI = "gemini"
|
|
26
|
+
AIDER = "aider"
|
|
27
|
+
CONTINUE = "continue"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AgentConfig:
|
|
32
|
+
"""Configuration for an AI agent."""
|
|
33
|
+
|
|
34
|
+
agent_type: AgentType
|
|
35
|
+
command: str # Base command to run
|
|
36
|
+
args: list[str] = field(default_factory=list)
|
|
37
|
+
env_vars: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
timeout: int = 600 # seconds
|
|
39
|
+
supports_yolo: bool = False
|
|
40
|
+
supports_session_resume: bool = False
|
|
41
|
+
supports_mcp: bool = False
|
|
42
|
+
cost_per_1k_input: float | None = None
|
|
43
|
+
cost_per_1k_output: float | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentExecutor(ABC):
|
|
47
|
+
"""Abstract base class for agent executors."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: AgentConfig):
|
|
50
|
+
self.config = config
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def is_available(self) -> bool:
|
|
54
|
+
"""Check if this agent is available on the system."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def build_command(
|
|
59
|
+
self,
|
|
60
|
+
prompt: str,
|
|
61
|
+
working_dir: Path,
|
|
62
|
+
yolo_mode: bool = False,
|
|
63
|
+
session_id: str | None = None,
|
|
64
|
+
**kwargs,
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
"""Build the command to execute the agent."""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
|
|
71
|
+
"""Parse agent output into structured format."""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ClaudeExecutor(AgentExecutor):
|
|
76
|
+
"""Executor for Claude Code CLI."""
|
|
77
|
+
|
|
78
|
+
def is_available(self) -> bool:
|
|
79
|
+
return shutil.which("claude") is not None
|
|
80
|
+
|
|
81
|
+
def build_command(
|
|
82
|
+
self,
|
|
83
|
+
prompt: str,
|
|
84
|
+
working_dir: Path,
|
|
85
|
+
yolo_mode: bool = False,
|
|
86
|
+
session_id: str | None = None,
|
|
87
|
+
**kwargs,
|
|
88
|
+
) -> list[str]:
|
|
89
|
+
cmd = [self.config.command, "--print", "--output-format", "json"]
|
|
90
|
+
|
|
91
|
+
if yolo_mode and self.config.supports_yolo:
|
|
92
|
+
cmd.append("--dangerously-skip-permissions")
|
|
93
|
+
|
|
94
|
+
if session_id and self.config.supports_session_resume:
|
|
95
|
+
cmd.extend(["--resume", session_id])
|
|
96
|
+
|
|
97
|
+
cmd.extend(["--prompt", prompt])
|
|
98
|
+
return cmd
|
|
99
|
+
|
|
100
|
+
def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
|
|
101
|
+
# Parse Claude's JSON output format
|
|
102
|
+
import json
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return {"success": True, "data": json.loads(stdout)}
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
return {"success": False, "raw_output": stdout, "error": stderr}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AmpExecutor(AgentExecutor):
|
|
111
|
+
"""Executor for Amp CLI."""
|
|
112
|
+
|
|
113
|
+
def is_available(self) -> bool:
|
|
114
|
+
return shutil.which("amp") is not None
|
|
115
|
+
|
|
116
|
+
def build_command(
|
|
117
|
+
self,
|
|
118
|
+
prompt: str,
|
|
119
|
+
working_dir: Path,
|
|
120
|
+
yolo_mode: bool = False,
|
|
121
|
+
session_id: str | None = None,
|
|
122
|
+
**kwargs,
|
|
123
|
+
) -> list[str]:
|
|
124
|
+
cmd = [self.config.command, "run"]
|
|
125
|
+
|
|
126
|
+
if session_id:
|
|
127
|
+
cmd.extend(["--thread", session_id])
|
|
128
|
+
|
|
129
|
+
cmd.extend(["--message", prompt])
|
|
130
|
+
return cmd
|
|
131
|
+
|
|
132
|
+
def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
|
|
133
|
+
return {"success": True, "raw_output": stdout}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CursorExecutor(AgentExecutor):
|
|
137
|
+
"""Executor for Cursor Agent CLI."""
|
|
138
|
+
|
|
139
|
+
def is_available(self) -> bool:
|
|
140
|
+
# Check common paths for cursor-agent
|
|
141
|
+
paths = [
|
|
142
|
+
shutil.which("cursor-agent"),
|
|
143
|
+
Path.home() / ".local/bin/cursor-agent",
|
|
144
|
+
Path("/usr/local/bin/cursor-agent"),
|
|
145
|
+
]
|
|
146
|
+
return any(p and (isinstance(p, str) or p.exists()) for p in paths)
|
|
147
|
+
|
|
148
|
+
def build_command(
|
|
149
|
+
self,
|
|
150
|
+
prompt: str,
|
|
151
|
+
working_dir: Path,
|
|
152
|
+
yolo_mode: bool = False,
|
|
153
|
+
session_id: str | None = None,
|
|
154
|
+
**kwargs,
|
|
155
|
+
) -> list[str]:
|
|
156
|
+
cmd = [self.config.command]
|
|
157
|
+
cmd.extend(["--prompt", prompt])
|
|
158
|
+
return cmd
|
|
159
|
+
|
|
160
|
+
def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
|
|
161
|
+
return {"success": True, "raw_output": stdout, "interactive": True}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AiderExecutor(AgentExecutor):
|
|
165
|
+
"""Executor for Aider CLI (open-source coding assistant)."""
|
|
166
|
+
|
|
167
|
+
def is_available(self) -> bool:
|
|
168
|
+
return shutil.which("aider") is not None
|
|
169
|
+
|
|
170
|
+
def build_command(
|
|
171
|
+
self,
|
|
172
|
+
prompt: str,
|
|
173
|
+
working_dir: Path,
|
|
174
|
+
yolo_mode: bool = False,
|
|
175
|
+
session_id: str | None = None,
|
|
176
|
+
**kwargs,
|
|
177
|
+
) -> list[str]:
|
|
178
|
+
cmd = [self.config.command, "--yes", "--no-auto-commits"]
|
|
179
|
+
|
|
180
|
+
if yolo_mode:
|
|
181
|
+
cmd.append("--auto-commits")
|
|
182
|
+
|
|
183
|
+
cmd.extend(["--message", prompt])
|
|
184
|
+
return cmd
|
|
185
|
+
|
|
186
|
+
def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
|
|
187
|
+
return {"success": True, "raw_output": stdout}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class GeminiExecutor(AgentExecutor):
|
|
191
|
+
"""Executor for Gemini CLI."""
|
|
192
|
+
|
|
193
|
+
def is_available(self) -> bool:
|
|
194
|
+
return shutil.which("gemini") is not None
|
|
195
|
+
|
|
196
|
+
def build_command(
|
|
197
|
+
self,
|
|
198
|
+
prompt: str,
|
|
199
|
+
working_dir: Path,
|
|
200
|
+
yolo_mode: bool = False,
|
|
201
|
+
session_id: str | None = None,
|
|
202
|
+
**kwargs,
|
|
203
|
+
) -> list[str]:
|
|
204
|
+
cmd = [self.config.command]
|
|
205
|
+
|
|
206
|
+
if yolo_mode:
|
|
207
|
+
cmd.extend(["--sandbox=false"])
|
|
208
|
+
|
|
209
|
+
cmd.extend(["--prompt", prompt])
|
|
210
|
+
return cmd
|
|
211
|
+
|
|
212
|
+
def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
|
|
213
|
+
return {"success": True, "raw_output": stdout}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class CodexExecutor(AgentExecutor):
|
|
217
|
+
"""Executor for OpenAI Codex CLI."""
|
|
218
|
+
|
|
219
|
+
def is_available(self) -> bool:
|
|
220
|
+
return shutil.which("codex") is not None
|
|
221
|
+
|
|
222
|
+
def build_command(
|
|
223
|
+
self,
|
|
224
|
+
prompt: str,
|
|
225
|
+
working_dir: Path,
|
|
226
|
+
yolo_mode: bool = False,
|
|
227
|
+
session_id: str | None = None,
|
|
228
|
+
**kwargs,
|
|
229
|
+
) -> list[str]:
|
|
230
|
+
cmd = [self.config.command]
|
|
231
|
+
|
|
232
|
+
if yolo_mode:
|
|
233
|
+
cmd.extend(["--approval-mode", "full-auto"])
|
|
234
|
+
|
|
235
|
+
cmd.extend([prompt])
|
|
236
|
+
return cmd
|
|
237
|
+
|
|
238
|
+
def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
|
|
239
|
+
return {"success": True, "raw_output": stdout}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# Agent registry with default configurations
|
|
243
|
+
AGENT_REGISTRY: dict[AgentType, AgentConfig] = {
|
|
244
|
+
AgentType.CLAUDE: AgentConfig(
|
|
245
|
+
agent_type=AgentType.CLAUDE,
|
|
246
|
+
command="claude",
|
|
247
|
+
supports_yolo=True,
|
|
248
|
+
supports_session_resume=True,
|
|
249
|
+
supports_mcp=True,
|
|
250
|
+
cost_per_1k_input=0.003,
|
|
251
|
+
cost_per_1k_output=0.015,
|
|
252
|
+
),
|
|
253
|
+
AgentType.CURSOR: AgentConfig(
|
|
254
|
+
agent_type=AgentType.CURSOR,
|
|
255
|
+
command="cursor-agent",
|
|
256
|
+
supports_yolo=False,
|
|
257
|
+
supports_session_resume=False,
|
|
258
|
+
),
|
|
259
|
+
AgentType.AMP: AgentConfig(
|
|
260
|
+
agent_type=AgentType.AMP,
|
|
261
|
+
command="amp",
|
|
262
|
+
supports_yolo=False,
|
|
263
|
+
supports_session_resume=True,
|
|
264
|
+
),
|
|
265
|
+
AgentType.AIDER: AgentConfig(
|
|
266
|
+
agent_type=AgentType.AIDER,
|
|
267
|
+
command="aider",
|
|
268
|
+
supports_yolo=True,
|
|
269
|
+
supports_session_resume=False,
|
|
270
|
+
cost_per_1k_input=0.003, # Depends on model used
|
|
271
|
+
cost_per_1k_output=0.015,
|
|
272
|
+
),
|
|
273
|
+
AgentType.GEMINI: AgentConfig(
|
|
274
|
+
agent_type=AgentType.GEMINI,
|
|
275
|
+
command="gemini",
|
|
276
|
+
supports_yolo=True,
|
|
277
|
+
supports_session_resume=False,
|
|
278
|
+
supports_mcp=False,
|
|
279
|
+
cost_per_1k_input=0.001,
|
|
280
|
+
cost_per_1k_output=0.002,
|
|
281
|
+
),
|
|
282
|
+
AgentType.CODEX: AgentConfig(
|
|
283
|
+
agent_type=AgentType.CODEX,
|
|
284
|
+
command="codex",
|
|
285
|
+
supports_yolo=True,
|
|
286
|
+
supports_session_resume=False,
|
|
287
|
+
cost_per_1k_input=0.01,
|
|
288
|
+
cost_per_1k_output=0.03,
|
|
289
|
+
),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
EXECUTOR_CLASSES: dict[AgentType, type] = {
|
|
293
|
+
AgentType.CLAUDE: ClaudeExecutor,
|
|
294
|
+
AgentType.CURSOR: CursorExecutor,
|
|
295
|
+
AgentType.AMP: AmpExecutor,
|
|
296
|
+
AgentType.AIDER: AiderExecutor,
|
|
297
|
+
AgentType.GEMINI: GeminiExecutor,
|
|
298
|
+
AgentType.CODEX: CodexExecutor,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AgentRegistry:
|
|
303
|
+
"""Registry for managing multiple AI coding agents."""
|
|
304
|
+
|
|
305
|
+
def __init__(self):
|
|
306
|
+
self._executors: dict[AgentType, AgentExecutor] = {}
|
|
307
|
+
|
|
308
|
+
def get_executor(self, agent_type: AgentType) -> AgentExecutor | None:
|
|
309
|
+
"""Get an executor for the specified agent type."""
|
|
310
|
+
if agent_type not in self._executors:
|
|
311
|
+
config = AGENT_REGISTRY.get(agent_type)
|
|
312
|
+
executor_class = EXECUTOR_CLASSES.get(agent_type)
|
|
313
|
+
if config and executor_class:
|
|
314
|
+
self._executors[agent_type] = executor_class(config)
|
|
315
|
+
|
|
316
|
+
return self._executors.get(agent_type)
|
|
317
|
+
|
|
318
|
+
def get_available_agents(self) -> list[AgentType]:
|
|
319
|
+
"""Get list of agents available on this system."""
|
|
320
|
+
available = []
|
|
321
|
+
for agent_type in AgentType:
|
|
322
|
+
executor = self.get_executor(agent_type)
|
|
323
|
+
if executor and executor.is_available():
|
|
324
|
+
available.append(agent_type)
|
|
325
|
+
return available
|
|
326
|
+
|
|
327
|
+
def get_agent_info(self, agent_type: AgentType) -> dict[str, Any] | None:
|
|
328
|
+
"""Get information about an agent."""
|
|
329
|
+
config = AGENT_REGISTRY.get(agent_type)
|
|
330
|
+
if not config:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
executor = self.get_executor(agent_type)
|
|
334
|
+
return {
|
|
335
|
+
"type": agent_type.value,
|
|
336
|
+
"available": executor.is_available() if executor else False,
|
|
337
|
+
"supports_yolo": config.supports_yolo,
|
|
338
|
+
"supports_session_resume": config.supports_session_resume,
|
|
339
|
+
"supports_mcp": config.supports_mcp,
|
|
340
|
+
"cost_per_1k_input": config.cost_per_1k_input,
|
|
341
|
+
"cost_per_1k_output": config.cost_per_1k_output,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Global registry instance
|
|
346
|
+
agent_registry = AgentRegistry()
|