devpilot-agentic-cli 1.0.0__py3-none-any.whl

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.
agent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # DevPilot Agent Package
agent/a2a_client.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ agent/a2a_client.py
3
+ ───────────────────
4
+ A2A client implementation. Discovers peer agents via their AgentCard
5
+ and delegates tasks to them using HTTP SSE.
6
+ """
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from agent.tools import ToolResult
14
+ from agent.ui import UI
15
+
16
+
17
+ async def _fetch_agent_card(peer_url: str, token: str | None) -> dict[str, Any]:
18
+ headers = {}
19
+ if token:
20
+ headers["Authorization"] = f"Bearer {token}"
21
+
22
+ card_url = f"{peer_url.rstrip('/')}/.well-known/agent.json"
23
+ async with httpx.AsyncClient() as client:
24
+ response = await client.get(card_url, headers=headers)
25
+ response.raise_for_status()
26
+ return response.json()
27
+
28
+
29
+ async def delegate_task_to_peer(peer_url: str, prompt: str, token: str | None = None) -> ToolResult:
30
+ """
31
+ Delegate a task to an A2A peer agent.
32
+ 1. Fetch AgentCard to find the /tasks/send endpoint.
33
+ 2. POST the task.
34
+ 3. Stream SSE events to get the artifact.
35
+ """
36
+ try:
37
+ card = await _fetch_agent_card(peer_url, token)
38
+ except Exception as e:
39
+ return ToolResult(f"Failed to fetch AgentCard from {peer_url}: {e}", is_error=True)
40
+
41
+ endpoints = card.get("endpoints", {})
42
+ send_url = endpoints.get("tasks")
43
+ if not send_url:
44
+ return ToolResult(f"Peer AgentCard missing 'tasks' endpoint. Card: {card}", is_error=True)
45
+
46
+ headers = {}
47
+ if token:
48
+ headers["Authorization"] = f"Bearer {token}"
49
+
50
+ try:
51
+ async with httpx.AsyncClient() as client:
52
+ # Send task
53
+ response = await client.post(send_url, json={"prompt": prompt}, headers=headers)
54
+ response.raise_for_status()
55
+ data = response.json()
56
+ task_id = data.get("task_id")
57
+
58
+ if not task_id:
59
+ return ToolResult("Peer did not return a task_id.", is_error=True)
60
+
61
+ base = send_url[: send_url.rfind("/send")]
62
+ stream_url = f"{base}/{task_id}/stream"
63
+
64
+ # Read SSE
65
+ UI.print_info(f"Delegated task {task_id} to {peer_url}. Waiting for results...")
66
+
67
+ final_artifact = ""
68
+ status = "working"
69
+
70
+ async with client.stream("GET", stream_url, headers=headers) as stream_resp:
71
+ stream_resp.raise_for_status()
72
+ async for line in stream_resp.aiter_lines():
73
+ line = line.strip()
74
+ if not line:
75
+ continue
76
+ if line.startswith("event: "):
77
+ current_event = line[7:]
78
+ elif line.startswith("data: "):
79
+ data_payload = line[6:]
80
+ if "current_event" in locals():
81
+ if current_event == "artifact":
82
+ final_artifact += data_payload + "\n"
83
+ elif current_event == "status":
84
+ status = data_payload
85
+
86
+ if status == "failed":
87
+ return ToolResult(f"Task failed on peer: {final_artifact}", is_error=True)
88
+
89
+ return ToolResult(f"Task completed successfully by peer. Artifact:\n{final_artifact}", is_error=False)
90
+
91
+ except Exception as e:
92
+ return ToolResult(f"A2A connection error: {e}", is_error=True)
93
+
94
+
agent/a2a_server.py ADDED
@@ -0,0 +1,148 @@
1
+ """
2
+ agent/a2a_server.py
3
+ ───────────────────
4
+ FastAPI server implementing the Agent-to-Agent (A2A) protocol.
5
+ Handles incoming task delegation requests and streams results via SSE.
6
+ """
7
+
8
+ import asyncio
9
+ import uuid
10
+ from typing import Any, AsyncGenerator
11
+
12
+ from fastapi import Depends, FastAPI, HTTPException, Request
13
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
14
+ from pydantic import BaseModel
15
+ from sse_starlette.sse import EventSourceResponse
16
+
17
+ from agent.config import Config
18
+ from agent.history import HistoryManager
19
+ from agent.loop import run_agent_loop
20
+ from agent.providers.factory import create_provider
21
+ from agent.tools import ToolRegistry
22
+
23
+ app = FastAPI(title="DevPilot A2A Server")
24
+ security = HTTPBearer(auto_error=False)
25
+
26
+ # Global task queues for SSE streaming
27
+ # task_id -> asyncio.Queue
28
+ _task_queues: dict[str, asyncio.Queue] = {}
29
+
30
+ # We'll store config, registry, etc in app.state during startup
31
+ # app.state.config
32
+ # app.state.registry
33
+
34
+
35
+ class TaskRequest(BaseModel):
36
+ prompt: str
37
+
38
+
39
+ def verify_token(credentials: HTTPAuthorizationCredentials | None = Depends(security)) -> None:
40
+ config: Config = app.state.config
41
+ if config.a2a_token:
42
+ if not credentials or credentials.credentials != config.a2a_token:
43
+ raise HTTPException(status_code=401, detail="Invalid or missing bearer token")
44
+
45
+
46
+ @app.get("/.well-known/agent.json")
47
+ async def get_agent_card() -> dict[str, Any]:
48
+ """Return the A2A AgentCard describing this agent."""
49
+ port = app.state.config.a2a_port
50
+ return {
51
+ "name": "DevPilot",
52
+ "description": "AI-Powered Terminal Coding Agent",
53
+ "protocols": ["A2A v0.2"],
54
+ "endpoints": {
55
+ "tasks": f"http://localhost:{port}/tasks/send"
56
+ }
57
+ }
58
+
59
+
60
+ async def background_task_runner(task_id: str, prompt: str) -> None:
61
+ """Executes the agentic loop in the background and pushes SSE events."""
62
+ queue = _task_queues[task_id]
63
+ await queue.put({"event": "status", "data": "working"})
64
+
65
+ try:
66
+ config: Config = app.state.config
67
+ registry: ToolRegistry = app.state.registry
68
+
69
+ # Instantiate a fresh provider and history for this task
70
+ provider = create_provider(config)
71
+ history = HistoryManager()
72
+
73
+ # Add the prompt
74
+ history.append(provider.make_user_message(prompt))
75
+
76
+ # Run loop. We force no_confirm=True for background tasks to avoid hanging.
77
+ # Use dataclasses.replace() so we don't duplicate every field.
78
+ import dataclasses
79
+ task_config = dataclasses.replace(config, no_confirm=True)
80
+
81
+ await run_agent_loop(
82
+ provider=provider,
83
+ registry=registry,
84
+ history=history,
85
+ config=task_config,
86
+ max_iterations=task_config.max_iterations,
87
+ )
88
+
89
+ # After loop finishes, the last message should be the assistant's final output
90
+ messages = history.get_messages()
91
+ if messages and messages[-1].get("role") == "assistant":
92
+ content = messages[-1].get("content", "")
93
+ if isinstance(content, list):
94
+ # Canonical format: list of typed blocks — extract text blocks only
95
+ final_text = " ".join(
96
+ b.get("text", "")
97
+ for b in content
98
+ if isinstance(b, dict) and b.get("type") == "text"
99
+ ).strip() or "Task completed."
100
+ else:
101
+ final_text = str(content)
102
+ else:
103
+ final_text = "Task completed, but no final response was generated."
104
+
105
+ await queue.put({"event": "artifact", "data": final_text})
106
+ await queue.put({"event": "status", "data": "completed"})
107
+
108
+ except Exception as e:
109
+ await queue.put({"event": "artifact", "data": f"Error: {e}"})
110
+ await queue.put({"event": "status", "data": "failed"})
111
+ finally:
112
+ await queue.put(None) # Sentinel to close stream
113
+
114
+
115
+ @app.post("/tasks/send", dependencies=[Depends(verify_token)])
116
+ async def create_task(request: TaskRequest) -> dict[str, str]:
117
+ """Receive a task, spin up background executor, and return task_id."""
118
+ task_id = str(uuid.uuid4())
119
+ _task_queues[task_id] = asyncio.Queue()
120
+
121
+ # Spin up background task
122
+ asyncio.create_task(background_task_runner(task_id, request.prompt))
123
+
124
+ return {"task_id": task_id}
125
+
126
+
127
+ @app.get("/tasks/{task_id}/stream", dependencies=[Depends(verify_token)])
128
+ async def stream_task(task_id: str, request: Request) -> EventSourceResponse:
129
+ """Stream SSE events for a specific task."""
130
+ if task_id not in _task_queues:
131
+ raise HTTPException(status_code=404, detail="Task not found")
132
+
133
+ queue = _task_queues[task_id]
134
+
135
+ async def event_generator() -> AsyncGenerator[dict[str, str], None]:
136
+ while True:
137
+ if await request.is_disconnected():
138
+ break
139
+
140
+ event = await queue.get()
141
+ if event is None:
142
+ # Task finished
143
+ del _task_queues[task_id]
144
+ break
145
+
146
+ yield event
147
+
148
+ return EventSourceResponse(event_generator())
agent/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ """
2
+ agent/cli.py
3
+ ─────────────
4
+ CLI entry point for DevPilot.
5
+ Parses arguments, runs first-run setup wizard if needed,
6
+ initializes components, and starts the interactive TUI or CI loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import os
14
+ import sys
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from agent.a2a_server import app as a2a_app
19
+ from agent.config import Config, ConfigError
20
+ from agent.history import HistoryManager
21
+ from agent.loop import run_agent_loop
22
+ from agent.mcp_client import MCPManager
23
+ from agent.providers.factory import create_provider
24
+ from agent.tools import ToolRegistry
25
+ from agent.ui import UI
26
+
27
+
28
+ def parse_args() -> argparse.Namespace:
29
+ parser = argparse.ArgumentParser(
30
+ prog="devpilot",
31
+ description="DevPilot — Autonomous AI coding agent for your terminal",
32
+ )
33
+ parser.add_argument("--provider", choices=["anthropic", "openai"], help="Model provider")
34
+ parser.add_argument("--model", help="Model name")
35
+ parser.add_argument("--base-url", help="OpenAI-compatible base URL (e.g. Ollama)")
36
+ parser.add_argument("--no-confirm", action="store_true", help="Skip confirmation prompts")
37
+ parser.add_argument("--thinking", action="store_true", help="Enable extended thinking (Claude only)")
38
+ parser.add_argument("--thinking-budget", type=int, help="Extended thinking token budget (default: 10000)")
39
+ parser.add_argument("--no-web-search", action="store_true", help="Disable web search tool")
40
+ parser.add_argument("--no-memory", action="store_true", help="Disable long-term memory")
41
+ parser.add_argument("--workdir", help="Working directory for file operations (default: cwd)")
42
+ parser.add_argument("--task", help="Run a single task and exit (CI mode)")
43
+ parser.add_argument("--resume", help="Resume a previous session by ID")
44
+ parser.add_argument("--a2a-port", type=int, help="A2A server port (default: 8000)")
45
+ parser.add_argument("--no-a2a", action="store_true", help="Disable A2A server")
46
+ parser.add_argument("--setup", action="store_true", help="Re-run the setup wizard")
47
+ return parser.parse_args()
48
+
49
+
50
+ def _apply_cli_overrides(args: argparse.Namespace) -> None:
51
+ """Push CLI flag values into env vars so Config.load() picks them up."""
52
+ if args.provider:
53
+ os.environ["DEVPILOT_PROVIDER"] = args.provider
54
+ if args.model:
55
+ os.environ["DEVPILOT_MODEL"] = args.model
56
+ if args.base_url:
57
+ os.environ["DEVPILOT_BASE_URL"] = args.base_url
58
+ if args.no_confirm:
59
+ os.environ["DEVPILOT_NO_CONFIRM"] = "true"
60
+ if args.thinking:
61
+ os.environ["DEVPILOT_THINKING"] = "true"
62
+ if args.thinking_budget:
63
+ os.environ["DEVPILOT_THINKING_BUDGET"] = str(args.thinking_budget)
64
+ if args.no_web_search:
65
+ os.environ["DEVPILOT_NO_WEB_SEARCH"] = "true"
66
+ if args.no_memory:
67
+ os.environ["DEVPILOT_NO_MEMORY"] = "true"
68
+ if args.workdir:
69
+ os.environ["DEVPILOT_WORKDIR"] = args.workdir
70
+ if args.a2a_port:
71
+ os.environ["DEVPILOT_A2A_PORT"] = str(args.a2a_port)
72
+ if args.no_a2a:
73
+ os.environ["DEVPILOT_NO_A2A"] = "true"
74
+
75
+
76
+ def _ensure_api_key(config: Config, args: argparse.Namespace) -> Config:
77
+ """
78
+ Check for a valid API key. If missing, run the setup wizard.
79
+ Re-loads config after wizard so the new key is picked up.
80
+ Returns the (possibly reloaded) config.
81
+ """
82
+ from agent.config import REQUIRED_ENV_KEYS
83
+ key_name = REQUIRED_ENV_KEYS[config.provider]
84
+
85
+ if config.active_api_key:
86
+ return config # Key present — nothing to do
87
+
88
+ # Force re-run wizard if --setup flag passed
89
+ if args.setup or not config.active_api_key:
90
+ from agent.setup_wizard import run_setup_wizard
91
+ success = run_setup_wizard(env_path=Path(".env"))
92
+ if not success:
93
+ # Wizard skipped (CI/no TTY) or failed — print helpful error
94
+ UI.print_error(
95
+ f"Missing API key for provider '{config.provider}'.\n"
96
+ f" Set {key_name} in your environment or .env file.\n"
97
+ f" Run 'devpilot --setup' to configure interactively."
98
+ )
99
+ sys.exit(1)
100
+
101
+ # Reload config so the new env vars are picked up
102
+ try:
103
+ config = Config.load()
104
+ _apply_cli_overrides(args)
105
+ config = Config.load()
106
+ except ConfigError as e:
107
+ UI.print_error(str(e))
108
+ sys.exit(1)
109
+
110
+ return config
111
+
112
+
113
+ async def main_async() -> None:
114
+ args = parse_args()
115
+ _apply_cli_overrides(args)
116
+
117
+ # Load config
118
+ try:
119
+ config = Config.load()
120
+ except ConfigError as e:
121
+ UI.print_error(str(e))
122
+ sys.exit(1)
123
+
124
+ # Handle --setup flag (force wizard even if key exists)
125
+ if args.setup:
126
+ from agent.setup_wizard import run_setup_wizard
127
+ run_setup_wizard(env_path=Path(".env"))
128
+ try:
129
+ config = Config.load()
130
+ except ConfigError as e:
131
+ UI.print_error(str(e))
132
+ sys.exit(1)
133
+ else:
134
+ # Ensure API key — runs wizard if missing
135
+ config = _ensure_api_key(config, args)
136
+
137
+ # Final key validation (catches invalid key format etc.)
138
+ try:
139
+ config.validate_api_key()
140
+ except ConfigError as e:
141
+ UI.print_error(str(e))
142
+ sys.exit(1)
143
+
144
+ # Wire up provider, context, registry
145
+ provider = create_provider(config)
146
+
147
+ from agent.context import RepoContext
148
+ repo_context = RepoContext(config.workdir)
149
+ registry = ToolRegistry(config, _context=repo_context)
150
+
151
+ # MCP
152
+ mcp_config_path = Path("mcp_servers.json")
153
+ if not mcp_config_path.exists():
154
+ mcp_config_path = Path(__file__).parent.parent / "mcp_servers.json"
155
+ mcp_manager = MCPManager(mcp_config_path)
156
+ await mcp_manager.connect_all(registry)
157
+
158
+ # A2A server
159
+ a2a_app.state.config = config
160
+ a2a_app.state.registry = registry
161
+
162
+ if config.a2a_enabled:
163
+ import uvicorn
164
+ a2a_cfg = uvicorn.Config(
165
+ app=a2a_app,
166
+ host="0.0.0.0",
167
+ port=config.a2a_port,
168
+ log_level="error",
169
+ )
170
+ a2a_server = uvicorn.Server(a2a_cfg)
171
+ a2a_task = asyncio.create_task(a2a_server.serve())
172
+ else:
173
+ a2a_server = None
174
+ a2a_task = None
175
+
176
+ # History / session
177
+ history = HistoryManager()
178
+ session_id = args.resume or datetime.now().strftime("%Y%m%d_%H%M%S")
179
+ session_file = config.sessions_dir / f"{session_id}.json"
180
+
181
+ if args.resume:
182
+ if session_file.exists():
183
+ history.load(session_file)
184
+ if args.task:
185
+ UI.print_info(f"Resumed session: {session_id}")
186
+ else:
187
+ UI.print_error(f"Session '{session_id}' not found at {session_file}")
188
+ sys.exit(1)
189
+ else:
190
+ if args.task:
191
+ UI.print_info(f"New session: {session_id}")
192
+
193
+ # ── CI / single-task mode ─────────────────────────────────────────────────
194
+ if args.task:
195
+ history.append(provider.make_user_message(args.task))
196
+ await run_agent_loop(
197
+ provider=provider,
198
+ registry=registry,
199
+ history=history,
200
+ config=config,
201
+ max_iterations=config.max_iterations,
202
+ context=repo_context,
203
+ )
204
+ history.save(session_file)
205
+ if a2a_server and a2a_task:
206
+ a2a_server.should_exit = True
207
+ await a2a_task
208
+ await mcp_manager.close()
209
+ sys.exit(0)
210
+
211
+ # ── Interactive TUI mode ──────────────────────────────────────────────────
212
+ from agent.tui.app import DevPilotApp
213
+ app = DevPilotApp(
214
+ provider=provider,
215
+ registry=registry,
216
+ history=history,
217
+ config=config,
218
+ repo_context=repo_context,
219
+ )
220
+ await app.run_async()
221
+
222
+ if a2a_server and a2a_task:
223
+ a2a_server.should_exit = True
224
+ await a2a_task
225
+ await mcp_manager.close()
226
+
227
+
228
+ def main() -> None:
229
+ asyncio.run(main_async())
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()