steerdev 0.4.27__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.
- steerdev-0.4.27.dist-info/METADATA +224 -0
- steerdev-0.4.27.dist-info/RECORD +57 -0
- steerdev-0.4.27.dist-info/WHEEL +4 -0
- steerdev-0.4.27.dist-info/entry_points.txt +2 -0
- steerdev_agent/__init__.py +10 -0
- steerdev_agent/api/__init__.py +32 -0
- steerdev_agent/api/activity.py +278 -0
- steerdev_agent/api/agents.py +145 -0
- steerdev_agent/api/client.py +158 -0
- steerdev_agent/api/commands.py +399 -0
- steerdev_agent/api/configs.py +238 -0
- steerdev_agent/api/context.py +306 -0
- steerdev_agent/api/events.py +294 -0
- steerdev_agent/api/hooks.py +178 -0
- steerdev_agent/api/implementation_plan.py +408 -0
- steerdev_agent/api/messages.py +231 -0
- steerdev_agent/api/prd.py +281 -0
- steerdev_agent/api/runs.py +526 -0
- steerdev_agent/api/sessions.py +403 -0
- steerdev_agent/api/specs.py +321 -0
- steerdev_agent/api/tasks.py +659 -0
- steerdev_agent/api/workflow_runs.py +351 -0
- steerdev_agent/api/workflows.py +191 -0
- steerdev_agent/cli.py +2254 -0
- steerdev_agent/config/__init__.py +19 -0
- steerdev_agent/config/models.py +236 -0
- steerdev_agent/config/platform.py +272 -0
- steerdev_agent/config/settings.py +62 -0
- steerdev_agent/daemon.py +675 -0
- steerdev_agent/executor/__init__.py +64 -0
- steerdev_agent/executor/base.py +121 -0
- steerdev_agent/executor/claude.py +328 -0
- steerdev_agent/executor/stream.py +163 -0
- steerdev_agent/git/__init__.py +1 -0
- steerdev_agent/handlers/__init__.py +5 -0
- steerdev_agent/handlers/prd.py +533 -0
- steerdev_agent/integration.py +334 -0
- steerdev_agent/prompt/__init__.py +10 -0
- steerdev_agent/prompt/builder.py +263 -0
- steerdev_agent/prompt/templates.py +422 -0
- steerdev_agent/py.typed +0 -0
- steerdev_agent/runner.py +829 -0
- steerdev_agent/setup/__init__.py +5 -0
- steerdev_agent/setup/claude_setup.py +560 -0
- steerdev_agent/setup/templates/claude_md_section.md +140 -0
- steerdev_agent/setup/templates/settings.json +69 -0
- steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
- steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
- steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
- steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
- steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
- steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
- steerdev_agent/setup/templates/steerdev.yaml +51 -0
- steerdev_agent/version.py +149 -0
- steerdev_agent/workflow/__init__.py +10 -0
- steerdev_agent/workflow/executor.py +494 -0
- steerdev_agent/workflow/memory.py +185 -0
steerdev_agent/runner.py
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
"""Simplified runner for steerdev using direct subprocess execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import UUID, uuid4
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from steerdev_agent.api.events import EventsClient
|
|
16
|
+
from steerdev_agent.api.runs import RunCreateRequest, RunsClient
|
|
17
|
+
from steerdev_agent.api.sessions import SessionCreateRequest, SessionsClient
|
|
18
|
+
from steerdev_agent.api.tasks import TasksClient
|
|
19
|
+
from steerdev_agent.config.models import ExecutorConfig
|
|
20
|
+
from steerdev_agent.executor import ExecutorFactory
|
|
21
|
+
from steerdev_agent.executor.base import AgentExecutor, EventType, StreamEvent
|
|
22
|
+
from steerdev_agent.executor.claude import ClaudeExecutorError
|
|
23
|
+
from steerdev_agent.prompt.builder import (
|
|
24
|
+
ProjectContext,
|
|
25
|
+
PromptBuilder,
|
|
26
|
+
PromptContext,
|
|
27
|
+
TaskContext,
|
|
28
|
+
WaveContext,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunnerError(Exception):
|
|
35
|
+
"""Error during runner execution."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RunState(str, Enum):
|
|
39
|
+
"""State machine states for the runner."""
|
|
40
|
+
|
|
41
|
+
STARTING = "starting"
|
|
42
|
+
RUNNING = "running"
|
|
43
|
+
STOPPING = "stopping"
|
|
44
|
+
STOPPED = "stopped"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Runner:
|
|
48
|
+
"""Simplified runner that executes CLI agents via subprocess.
|
|
49
|
+
|
|
50
|
+
This class:
|
|
51
|
+
- Fetches tasks from the steerdev.com API
|
|
52
|
+
- Builds prompts using PromptBuilder
|
|
53
|
+
- Creates sessions via the API
|
|
54
|
+
- Executes agents (Claude Code) as subprocesses
|
|
55
|
+
- Streams events to the API for storage
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
project_id: str,
|
|
61
|
+
working_directory: str | Path | None = None,
|
|
62
|
+
api_key: str | None = None,
|
|
63
|
+
agent_type: str = "claude",
|
|
64
|
+
agent_name: str | None = None,
|
|
65
|
+
model: str | None = None,
|
|
66
|
+
max_turns: int | None = None,
|
|
67
|
+
max_tasks: int = 1,
|
|
68
|
+
timeout_seconds: int = 3600,
|
|
69
|
+
enable_worktrees: bool = False,
|
|
70
|
+
executor_config: ExecutorConfig | None = None,
|
|
71
|
+
workflow_id: str | None = None,
|
|
72
|
+
dry_run: bool = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Initialize the runner.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
project_id: SteerDev project ID.
|
|
78
|
+
working_directory: Directory to run the agent in.
|
|
79
|
+
api_key: API key for authentication.
|
|
80
|
+
agent_type: Type of agent to use (claude, codex, aider).
|
|
81
|
+
agent_name: Name of the agent (creates agent if needed).
|
|
82
|
+
model: Model to use for the agent.
|
|
83
|
+
max_turns: Maximum number of agent turns per task.
|
|
84
|
+
max_tasks: Maximum number of tasks to process (0 for unlimited).
|
|
85
|
+
timeout_seconds: Timeout for the entire run.
|
|
86
|
+
enable_worktrees: Enable Claude CLI --worktree isolation.
|
|
87
|
+
executor_config: Executor configuration (tools, permissions).
|
|
88
|
+
workflow_id: Workflow ID for multi-phase execution.
|
|
89
|
+
dry_run: If True, print the command without executing it.
|
|
90
|
+
"""
|
|
91
|
+
self.project_id = project_id
|
|
92
|
+
self.working_directory = Path(working_directory or Path.cwd())
|
|
93
|
+
self._api_key = api_key
|
|
94
|
+
self.agent_type = agent_type
|
|
95
|
+
self.agent_name = agent_name
|
|
96
|
+
self.model = model
|
|
97
|
+
self.max_turns = max_turns
|
|
98
|
+
self.max_tasks = max_tasks if max_tasks != 0 else None # 0 = unlimited
|
|
99
|
+
self.timeout_seconds = timeout_seconds
|
|
100
|
+
self.workflow_id = workflow_id
|
|
101
|
+
self.dry_run = dry_run
|
|
102
|
+
|
|
103
|
+
# Executor configuration
|
|
104
|
+
self._executor_config = executor_config or ExecutorConfig()
|
|
105
|
+
|
|
106
|
+
# Worktree configuration
|
|
107
|
+
self._enable_worktrees = enable_worktrees
|
|
108
|
+
|
|
109
|
+
# State
|
|
110
|
+
self._state = RunState.STOPPED
|
|
111
|
+
self._run_id = uuid4()
|
|
112
|
+
self._db_run_id: str | None = None # Database primary key for the run record
|
|
113
|
+
self._session_id: str | None = None
|
|
114
|
+
self._started_at: datetime | None = None
|
|
115
|
+
self._stopped_at: datetime | None = None
|
|
116
|
+
|
|
117
|
+
# Stats
|
|
118
|
+
self._tasks_executed = 0
|
|
119
|
+
self._tasks_succeeded = 0
|
|
120
|
+
self._tasks_failed = 0
|
|
121
|
+
self._events_sent = 0
|
|
122
|
+
|
|
123
|
+
# Components
|
|
124
|
+
self._sessions_client: SessionsClient | None = None
|
|
125
|
+
self._events_client: EventsClient | None = None
|
|
126
|
+
self._executor: AgentExecutor | None = None
|
|
127
|
+
self._prompt_builder = PromptBuilder()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def run_id(self) -> UUID:
|
|
131
|
+
"""Get the run ID."""
|
|
132
|
+
return self._run_id
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def session_id(self) -> str | None:
|
|
136
|
+
"""Get the current session ID."""
|
|
137
|
+
return self._session_id
|
|
138
|
+
|
|
139
|
+
async def _create_session(
|
|
140
|
+
self,
|
|
141
|
+
task_id: str | None,
|
|
142
|
+
prompt: str,
|
|
143
|
+
) -> str | None:
|
|
144
|
+
"""Create a session via the API.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
task_id: Optional task ID being executed.
|
|
148
|
+
prompt: The prompt being sent to the agent.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Session ID or None on failure.
|
|
152
|
+
"""
|
|
153
|
+
if self._sessions_client is None:
|
|
154
|
+
self._sessions_client = SessionsClient(api_key=self._api_key)
|
|
155
|
+
|
|
156
|
+
request = SessionCreateRequest(
|
|
157
|
+
project_id=self.project_id,
|
|
158
|
+
task_id=task_id,
|
|
159
|
+
agent_name=self.agent_name,
|
|
160
|
+
agent_type=self.agent_type,
|
|
161
|
+
prompt=prompt,
|
|
162
|
+
working_directory=str(self.working_directory),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
session = await self._sessions_client.create_session(request)
|
|
166
|
+
if session:
|
|
167
|
+
self._session_id = session.id
|
|
168
|
+
logger.info(f"Session created: {session.id}")
|
|
169
|
+
return session.id
|
|
170
|
+
|
|
171
|
+
logger.warning("Failed to create session")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
async def _create_run_record(self) -> str | None:
|
|
175
|
+
"""Create a run record in the API and return the database ID.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The database primary key (id) for the run, or None on failure.
|
|
179
|
+
"""
|
|
180
|
+
runs_client = RunsClient(api_key=self._api_key)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
request = RunCreateRequest(
|
|
184
|
+
run_id=str(self._run_id),
|
|
185
|
+
session_name=f"steerdev-{str(self._run_id)[:8]}",
|
|
186
|
+
working_directory=str(self.working_directory),
|
|
187
|
+
application=self.agent_type,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
run = await runs_client.create_run(request)
|
|
191
|
+
if run:
|
|
192
|
+
logger.info(f"Run record created: {run.id}")
|
|
193
|
+
return run.id
|
|
194
|
+
|
|
195
|
+
logger.warning("Failed to create run record")
|
|
196
|
+
return None
|
|
197
|
+
finally:
|
|
198
|
+
await runs_client.close()
|
|
199
|
+
|
|
200
|
+
async def _stream_events_to_api(self, event: StreamEvent) -> None:
|
|
201
|
+
"""Stream an event to the API.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
event: The event to stream.
|
|
205
|
+
"""
|
|
206
|
+
if self._events_client is None:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
await self._events_client.add_event(
|
|
210
|
+
event_type=event.event_type.value,
|
|
211
|
+
data=event.data,
|
|
212
|
+
raw_json=event.raw_json,
|
|
213
|
+
timestamp=event.timestamp,
|
|
214
|
+
)
|
|
215
|
+
self._events_sent += 1
|
|
216
|
+
|
|
217
|
+
async def _execute_task(
|
|
218
|
+
self,
|
|
219
|
+
task: dict[str, Any],
|
|
220
|
+
project: dict[str, Any] | None = None,
|
|
221
|
+
wave_context: WaveContext | None = None,
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
"""Execute a single task.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
task: Task data from the API.
|
|
227
|
+
project: Optional project data.
|
|
228
|
+
wave_context: Optional wave context for wave-aware execution.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Execution result.
|
|
232
|
+
"""
|
|
233
|
+
task_id = task.get("id", "")
|
|
234
|
+
task_title = task.get("title", "Unknown")
|
|
235
|
+
|
|
236
|
+
console.print(f"\n[bold cyan]Starting task: {task_title}[/bold cyan]")
|
|
237
|
+
console.print(f"[dim]Task ID: {task_id}[/dim]")
|
|
238
|
+
|
|
239
|
+
# Check if workflow execution is enabled
|
|
240
|
+
if self.workflow_id:
|
|
241
|
+
return await self._execute_task_with_workflow(task)
|
|
242
|
+
|
|
243
|
+
# Build prompt
|
|
244
|
+
context = PromptContext(
|
|
245
|
+
project=ProjectContext(**project) if project else None,
|
|
246
|
+
task=TaskContext(
|
|
247
|
+
id=task_id,
|
|
248
|
+
title=task_title,
|
|
249
|
+
prompt=task.get("prompt", ""),
|
|
250
|
+
status=task.get("status", "unstarted"),
|
|
251
|
+
priority=task.get("priority", 3),
|
|
252
|
+
working_directory=task.get("working_directory"),
|
|
253
|
+
wave=wave_context,
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
prompt = self._prompt_builder.build(context)
|
|
257
|
+
|
|
258
|
+
# Create session (skip in dry run mode)
|
|
259
|
+
if not self.dry_run:
|
|
260
|
+
session_id = await self._create_session(task_id, prompt)
|
|
261
|
+
if not session_id:
|
|
262
|
+
return {"success": False, "error": "Failed to create session"}
|
|
263
|
+
else:
|
|
264
|
+
session_id = "dry-run-session"
|
|
265
|
+
|
|
266
|
+
# Initialize events client (skip in dry run mode)
|
|
267
|
+
if not self.dry_run:
|
|
268
|
+
self._events_client = EventsClient(
|
|
269
|
+
session_id=session_id,
|
|
270
|
+
api_key=self._api_key,
|
|
271
|
+
)
|
|
272
|
+
await self._events_client.start()
|
|
273
|
+
|
|
274
|
+
# Mark session as running (skip in dry run mode)
|
|
275
|
+
if self._sessions_client and not self.dry_run:
|
|
276
|
+
await self._sessions_client.mark_running(session_id)
|
|
277
|
+
|
|
278
|
+
# Compute worktree name if enabled
|
|
279
|
+
worktree_name: str | None = None
|
|
280
|
+
if self._enable_worktrees and not self.dry_run:
|
|
281
|
+
if wave_context:
|
|
282
|
+
worktree_name = f"wave-{wave_context.wave_number}"
|
|
283
|
+
else:
|
|
284
|
+
task_short = task_id[:8] if len(task_id) > 8 else task_id
|
|
285
|
+
worktree_name = f"task-{task_short}"
|
|
286
|
+
console.print(f"[dim]Using worktree: {worktree_name}[/dim]")
|
|
287
|
+
|
|
288
|
+
# Create executor using factory
|
|
289
|
+
self._executor = ExecutorFactory.create(
|
|
290
|
+
config=self._executor_config,
|
|
291
|
+
working_directory=str(self.working_directory),
|
|
292
|
+
model=self.model,
|
|
293
|
+
max_turns=self.max_turns,
|
|
294
|
+
dry_run=self.dry_run,
|
|
295
|
+
worktree_name=worktree_name,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# Start the agent
|
|
300
|
+
await self._executor.start(prompt)
|
|
301
|
+
console.print("[dim]Agent started, streaming output...[/dim]")
|
|
302
|
+
|
|
303
|
+
# Stream events
|
|
304
|
+
async for event in self._executor.stream_events():
|
|
305
|
+
await self._stream_events_to_api(event)
|
|
306
|
+
|
|
307
|
+
# Log key events
|
|
308
|
+
if event.event_type == EventType.ASSISTANT:
|
|
309
|
+
message = event.data.get("message", {})
|
|
310
|
+
content = message.get("content", "")
|
|
311
|
+
if isinstance(content, str):
|
|
312
|
+
preview = content[:100] + "..." if len(content) > 100 else content
|
|
313
|
+
console.print(f"[cyan]Assistant:[/cyan] {preview}")
|
|
314
|
+
|
|
315
|
+
if event.is_final:
|
|
316
|
+
console.print("[green]Agent completed[/green]")
|
|
317
|
+
|
|
318
|
+
# Wait for completion
|
|
319
|
+
exit_code = await self._executor.wait()
|
|
320
|
+
|
|
321
|
+
# Update session with agent session ID (skip in dry run mode)
|
|
322
|
+
agent_session_id = self._executor.session_id
|
|
323
|
+
|
|
324
|
+
# Check for failure and get stderr
|
|
325
|
+
if exit_code != 0:
|
|
326
|
+
stderr = await self._executor.get_stderr()
|
|
327
|
+
error_msg = stderr.strip() if stderr else f"Process exited with code {exit_code}"
|
|
328
|
+
logger.error(f"Claude failed with exit code {exit_code}: {error_msg}")
|
|
329
|
+
if self._sessions_client and not self.dry_run:
|
|
330
|
+
await self._sessions_client.mark_failed(
|
|
331
|
+
session_id,
|
|
332
|
+
metadata={"error": error_msg, "exit_code": exit_code},
|
|
333
|
+
)
|
|
334
|
+
return {
|
|
335
|
+
"success": False,
|
|
336
|
+
"exit_code": exit_code,
|
|
337
|
+
"error": error_msg,
|
|
338
|
+
"agent_session_id": agent_session_id,
|
|
339
|
+
"events_sent": self._events_sent,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if self._sessions_client and agent_session_id and not self.dry_run:
|
|
343
|
+
await self._sessions_client.mark_completed(
|
|
344
|
+
session_id,
|
|
345
|
+
agent_session_id=agent_session_id,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
"success": True,
|
|
350
|
+
"exit_code": exit_code,
|
|
351
|
+
"agent_session_id": agent_session_id,
|
|
352
|
+
"events_sent": self._events_sent,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
except ClaudeExecutorError as e:
|
|
356
|
+
logger.error(f"Executor error: {e}")
|
|
357
|
+
if self._sessions_client and not self.dry_run:
|
|
358
|
+
await self._sessions_client.mark_failed(
|
|
359
|
+
session_id,
|
|
360
|
+
metadata={"error": str(e)},
|
|
361
|
+
)
|
|
362
|
+
return {"success": False, "error": str(e)}
|
|
363
|
+
|
|
364
|
+
finally:
|
|
365
|
+
# Stop events client
|
|
366
|
+
if self._events_client:
|
|
367
|
+
await self._events_client.close()
|
|
368
|
+
self._events_client = None
|
|
369
|
+
|
|
370
|
+
# Stop executor if still running
|
|
371
|
+
if self._executor and self._executor.is_running:
|
|
372
|
+
await self._executor.stop()
|
|
373
|
+
self._executor = None
|
|
374
|
+
|
|
375
|
+
async def _execute_task_with_workflow(
|
|
376
|
+
self,
|
|
377
|
+
task: dict[str, Any],
|
|
378
|
+
) -> dict[str, Any]:
|
|
379
|
+
"""Execute a task using workflow-based multi-phase execution.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
task: Task data from the API.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Execution result.
|
|
386
|
+
"""
|
|
387
|
+
from steerdev_agent.workflow import WorkflowExecutor
|
|
388
|
+
|
|
389
|
+
task_id = task.get("id", "")
|
|
390
|
+
task_title = task.get("title", "Unknown")
|
|
391
|
+
|
|
392
|
+
console.print(f"[dim]Using workflow: {self.workflow_id}[/dim]")
|
|
393
|
+
|
|
394
|
+
# Build task context for workflow
|
|
395
|
+
task_context = {
|
|
396
|
+
"task_id": task_id,
|
|
397
|
+
"task_title": task_title,
|
|
398
|
+
"task_prompt": task.get("prompt", ""),
|
|
399
|
+
"task_status": task.get("status", "unstarted"),
|
|
400
|
+
"task_priority": task.get("priority", 3),
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Create workflow executor
|
|
404
|
+
workflow_executor = WorkflowExecutor(
|
|
405
|
+
working_directory=self.working_directory,
|
|
406
|
+
api_key=self._api_key,
|
|
407
|
+
executor_config=self._executor_config,
|
|
408
|
+
model=self.model,
|
|
409
|
+
max_turns=self.max_turns,
|
|
410
|
+
dry_run=self.dry_run,
|
|
411
|
+
project_id=self.project_id,
|
|
412
|
+
agent_type=self.agent_type,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
result = await workflow_executor.execute_workflow(
|
|
417
|
+
workflow_id=self.workflow_id, # type: ignore # workflow_id is checked before call
|
|
418
|
+
task_context=task_context,
|
|
419
|
+
run_id=self._db_run_id, # Pass the database ID, not the client-generated UUID
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
"success": result.get("success", False),
|
|
424
|
+
"workflow_run_id": result.get("workflow_run_id"),
|
|
425
|
+
"phases_completed": result.get("phases_completed", 0),
|
|
426
|
+
"phases_failed": result.get("phases_failed", 0),
|
|
427
|
+
"total_phases": result.get("total_phases", 0),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
except Exception as e:
|
|
431
|
+
logger.error(f"Workflow execution failed: {e}")
|
|
432
|
+
return {"success": False, "error": str(e)}
|
|
433
|
+
|
|
434
|
+
async def run(
|
|
435
|
+
self,
|
|
436
|
+
task_id: str | None = None,
|
|
437
|
+
) -> dict[str, Any]:
|
|
438
|
+
"""Run the agent.
|
|
439
|
+
|
|
440
|
+
If task_id is provided, runs that specific task.
|
|
441
|
+
Otherwise, fetches the next available task.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
task_id: Optional specific task ID to run.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Run result metadata.
|
|
448
|
+
"""
|
|
449
|
+
self._state = RunState.STARTING
|
|
450
|
+
self._started_at = datetime.now(UTC)
|
|
451
|
+
run_error: Exception | None = None
|
|
452
|
+
|
|
453
|
+
console.print(
|
|
454
|
+
Panel(
|
|
455
|
+
f"[bold blue]SteerDev Agent[/bold blue]\n"
|
|
456
|
+
f"Project ID: {self.project_id}\n"
|
|
457
|
+
f"Working Directory: {self.working_directory}\n"
|
|
458
|
+
f"Agent Type: {self.agent_type}\n"
|
|
459
|
+
f"Timeout: {self.timeout_seconds}s",
|
|
460
|
+
title="Starting",
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
self._state = RunState.RUNNING
|
|
466
|
+
|
|
467
|
+
# Create run record for tracking (only needed for workflow execution)
|
|
468
|
+
if self.workflow_id and not self.dry_run:
|
|
469
|
+
self._db_run_id = await self._create_run_record()
|
|
470
|
+
if not self._db_run_id:
|
|
471
|
+
logger.warning(
|
|
472
|
+
"Could not create run record, workflow associations will be null"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# If specific task_id provided, just run that one task
|
|
476
|
+
if task_id:
|
|
477
|
+
with TasksClient(api_key=self._api_key) as client:
|
|
478
|
+
task = client.get_task(task_id)
|
|
479
|
+
if not task:
|
|
480
|
+
raise RunnerError(f"Task not found: {task_id}")
|
|
481
|
+
|
|
482
|
+
self._tasks_executed += 1
|
|
483
|
+
result = await self._execute_task(task)
|
|
484
|
+
|
|
485
|
+
if result.get("success"):
|
|
486
|
+
self._tasks_succeeded += 1
|
|
487
|
+
console.print("[green]Task completed successfully[/green]")
|
|
488
|
+
else:
|
|
489
|
+
self._tasks_failed += 1
|
|
490
|
+
console.print(f"[red]Task failed: {result.get('error', 'Unknown')}[/red]")
|
|
491
|
+
else:
|
|
492
|
+
# Multi-task loop
|
|
493
|
+
tasks_remaining = self.max_tasks # None = unlimited
|
|
494
|
+
|
|
495
|
+
while tasks_remaining is None or tasks_remaining > 0:
|
|
496
|
+
# Try wave-aware fetch first, then fall back to single-task
|
|
497
|
+
task = None
|
|
498
|
+
wave_ctx: WaveContext | None = None
|
|
499
|
+
|
|
500
|
+
with TasksClient(api_key=self._api_key) as client:
|
|
501
|
+
wave_response = client.get_next_wave(project_id=self.project_id)
|
|
502
|
+
if wave_response and "context" in wave_response:
|
|
503
|
+
next_task_data = wave_response.get("context", {}).get("next_task")
|
|
504
|
+
if next_task_data:
|
|
505
|
+
task = next_task_data
|
|
506
|
+
# Build wave context for the prompt
|
|
507
|
+
wave_info = wave_response.get("wave", {})
|
|
508
|
+
ctx = wave_response.get("context", {})
|
|
509
|
+
wave_tasks = wave_response.get("tasks", [])
|
|
510
|
+
|
|
511
|
+
# Build tasks summary
|
|
512
|
+
task_lines = []
|
|
513
|
+
for wt in wave_tasks:
|
|
514
|
+
status_icon = {
|
|
515
|
+
"completed": "[done]",
|
|
516
|
+
"started": "[in-progress]",
|
|
517
|
+
"canceled": "[canceled]",
|
|
518
|
+
}.get(wt.get("status", ""), "[todo]")
|
|
519
|
+
task_lines.append(
|
|
520
|
+
f" {status_icon} {wt.get('title', 'Unknown')}"
|
|
521
|
+
)
|
|
522
|
+
tasks_summary = "\n".join(task_lines)
|
|
523
|
+
|
|
524
|
+
# Build completed waves summary
|
|
525
|
+
completed = ctx.get("completed_waves", [])
|
|
526
|
+
completed_lines = [
|
|
527
|
+
f" - Wave {cw.get('wave_number', '?')}: {cw.get('description', '')}"
|
|
528
|
+
for cw in completed
|
|
529
|
+
]
|
|
530
|
+
completed_summary = (
|
|
531
|
+
"\n".join(completed_lines) if completed_lines else ""
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
wave_ctx = WaveContext(
|
|
535
|
+
wave_number=wave_info.get("wave_number", 1),
|
|
536
|
+
total_waves=wave_info.get("total_waves", 1),
|
|
537
|
+
wave_description=wave_info.get("description", ""),
|
|
538
|
+
wave_tasks_summary=tasks_summary,
|
|
539
|
+
completed_waves_summary=completed_summary,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if not task:
|
|
543
|
+
task = client.get_next_task(project_id=self.project_id)
|
|
544
|
+
|
|
545
|
+
if not task:
|
|
546
|
+
if self._tasks_executed == 0:
|
|
547
|
+
console.print("[yellow]No tasks available[/yellow]")
|
|
548
|
+
else:
|
|
549
|
+
console.print("[yellow]No more tasks available[/yellow]")
|
|
550
|
+
break
|
|
551
|
+
|
|
552
|
+
# Execute task
|
|
553
|
+
self._tasks_executed += 1
|
|
554
|
+
task_title = task.get("title", "Unknown")
|
|
555
|
+
console.print(f"\n[bold]Task {self._tasks_executed}: {task_title}[/bold]")
|
|
556
|
+
|
|
557
|
+
result = await self._execute_task(task, wave_context=wave_ctx)
|
|
558
|
+
|
|
559
|
+
if result.get("success"):
|
|
560
|
+
self._tasks_succeeded += 1
|
|
561
|
+
console.print("[green]Task completed successfully[/green]")
|
|
562
|
+
else:
|
|
563
|
+
self._tasks_failed += 1
|
|
564
|
+
console.print(f"[red]Task failed: {result.get('error', 'Unknown')}[/red]")
|
|
565
|
+
# Continue to next task on failure
|
|
566
|
+
|
|
567
|
+
# Decrement counter if not unlimited
|
|
568
|
+
if tasks_remaining is not None:
|
|
569
|
+
tasks_remaining -= 1
|
|
570
|
+
|
|
571
|
+
except Exception as e:
|
|
572
|
+
logger.exception(f"Error during run: {e}")
|
|
573
|
+
console.print(f"\n[red]Error during run: {e}[/red]")
|
|
574
|
+
run_error = e
|
|
575
|
+
|
|
576
|
+
finally:
|
|
577
|
+
self._state = RunState.STOPPING
|
|
578
|
+
self._stopped_at = datetime.now(UTC)
|
|
579
|
+
|
|
580
|
+
# Complete or fail the run record
|
|
581
|
+
if self._db_run_id:
|
|
582
|
+
runs_client = RunsClient(api_key=self._api_key)
|
|
583
|
+
try:
|
|
584
|
+
if run_error:
|
|
585
|
+
await runs_client.fail_run(
|
|
586
|
+
self._db_run_id,
|
|
587
|
+
error_message=str(run_error),
|
|
588
|
+
)
|
|
589
|
+
else:
|
|
590
|
+
await runs_client.complete_run(
|
|
591
|
+
self._db_run_id,
|
|
592
|
+
tasks_executed=self._tasks_executed,
|
|
593
|
+
tasks_succeeded=self._tasks_succeeded,
|
|
594
|
+
tasks_failed=self._tasks_failed,
|
|
595
|
+
)
|
|
596
|
+
finally:
|
|
597
|
+
await runs_client.close()
|
|
598
|
+
|
|
599
|
+
# Cleanup
|
|
600
|
+
if self._sessions_client:
|
|
601
|
+
await self._sessions_client.close()
|
|
602
|
+
self._sessions_client = None
|
|
603
|
+
|
|
604
|
+
self._state = RunState.STOPPED
|
|
605
|
+
|
|
606
|
+
# Calculate duration
|
|
607
|
+
duration = 0.0
|
|
608
|
+
if self._started_at and self._stopped_at:
|
|
609
|
+
duration = (self._stopped_at - self._started_at).total_seconds()
|
|
610
|
+
|
|
611
|
+
# Show summary
|
|
612
|
+
console.print("\n" + "=" * 60)
|
|
613
|
+
console.print("[bold]Run Summary[/bold]")
|
|
614
|
+
console.print(f" Run ID: {self._run_id}")
|
|
615
|
+
console.print(f" Tasks Executed: {self._tasks_executed}")
|
|
616
|
+
console.print(f" Succeeded: {self._tasks_succeeded}")
|
|
617
|
+
console.print(f" Failed: {self._tasks_failed}")
|
|
618
|
+
console.print(f" Events Sent: {self._events_sent}")
|
|
619
|
+
console.print(f" Duration: {duration:.1f}s")
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
"run_id": str(self._run_id),
|
|
623
|
+
"session_id": self._session_id,
|
|
624
|
+
"started_at": self._started_at.isoformat() if self._started_at else None,
|
|
625
|
+
"stopped_at": self._stopped_at.isoformat() if self._stopped_at else None,
|
|
626
|
+
"duration_seconds": duration,
|
|
627
|
+
"tasks_executed": self._tasks_executed,
|
|
628
|
+
"tasks_succeeded": self._tasks_succeeded,
|
|
629
|
+
"tasks_failed": self._tasks_failed,
|
|
630
|
+
"events_sent": self._events_sent,
|
|
631
|
+
"error": str(run_error) if run_error else None,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async def resume(
|
|
635
|
+
self,
|
|
636
|
+
session_id: str,
|
|
637
|
+
message: str,
|
|
638
|
+
) -> dict[str, Any]:
|
|
639
|
+
"""Resume an existing session with a new message.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
session_id: The session ID to resume.
|
|
643
|
+
message: The message to continue with.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
Run result metadata.
|
|
647
|
+
"""
|
|
648
|
+
self._state = RunState.STARTING
|
|
649
|
+
self._started_at = datetime.now(UTC)
|
|
650
|
+
|
|
651
|
+
console.print(
|
|
652
|
+
Panel(
|
|
653
|
+
f"[bold blue]Resuming Session[/bold blue]\n"
|
|
654
|
+
f"Session ID: {session_id}\n"
|
|
655
|
+
f"Message: {message[:100]}...",
|
|
656
|
+
title="Resume",
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
try:
|
|
661
|
+
self._state = RunState.RUNNING
|
|
662
|
+
|
|
663
|
+
# Get session details
|
|
664
|
+
self._sessions_client = SessionsClient(api_key=self._api_key)
|
|
665
|
+
session = await self._sessions_client.get_session(session_id)
|
|
666
|
+
|
|
667
|
+
if not session:
|
|
668
|
+
raise RunnerError(f"Session not found: {session_id}")
|
|
669
|
+
|
|
670
|
+
if not session.agent_session_id:
|
|
671
|
+
raise RunnerError("Session has no agent_session_id for resume")
|
|
672
|
+
|
|
673
|
+
# Initialize events client
|
|
674
|
+
self._events_client = EventsClient(
|
|
675
|
+
session_id=session_id,
|
|
676
|
+
api_key=self._api_key,
|
|
677
|
+
)
|
|
678
|
+
await self._events_client.start()
|
|
679
|
+
|
|
680
|
+
# Create executor using factory
|
|
681
|
+
self._executor = ExecutorFactory.create(
|
|
682
|
+
config=self._executor_config,
|
|
683
|
+
working_directory=session.working_directory,
|
|
684
|
+
model=self.model,
|
|
685
|
+
dry_run=self.dry_run,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Resume the session
|
|
689
|
+
await self._executor.resume(session.agent_session_id, message)
|
|
690
|
+
console.print("[dim]Session resumed, streaming output...[/dim]")
|
|
691
|
+
|
|
692
|
+
# Stream events
|
|
693
|
+
async for event in self._executor.stream_events():
|
|
694
|
+
await self._stream_events_to_api(event)
|
|
695
|
+
|
|
696
|
+
if event.event_type == EventType.ASSISTANT:
|
|
697
|
+
msg = event.data.get("message", {})
|
|
698
|
+
content = msg.get("content", "")
|
|
699
|
+
if isinstance(content, str):
|
|
700
|
+
preview = content[:100] + "..." if len(content) > 100 else content
|
|
701
|
+
console.print(f"[cyan]Assistant:[/cyan] {preview}")
|
|
702
|
+
|
|
703
|
+
# Wait for completion
|
|
704
|
+
exit_code = await self._executor.wait()
|
|
705
|
+
|
|
706
|
+
# Update session
|
|
707
|
+
new_agent_session_id = self._executor.session_id
|
|
708
|
+
if new_agent_session_id:
|
|
709
|
+
await self._sessions_client.mark_completed(
|
|
710
|
+
session_id,
|
|
711
|
+
agent_session_id=new_agent_session_id,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
"success": exit_code == 0,
|
|
716
|
+
"exit_code": exit_code,
|
|
717
|
+
"agent_session_id": new_agent_session_id,
|
|
718
|
+
"events_sent": self._events_sent,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
except Exception as e:
|
|
722
|
+
logger.exception(f"Error during resume: {e}")
|
|
723
|
+
console.print(f"\n[red]Error during resume: {e}[/red]")
|
|
724
|
+
return {"success": False, "error": str(e)}
|
|
725
|
+
|
|
726
|
+
finally:
|
|
727
|
+
self._state = RunState.STOPPING
|
|
728
|
+
self._stopped_at = datetime.now(UTC)
|
|
729
|
+
|
|
730
|
+
if self._events_client:
|
|
731
|
+
await self._events_client.close()
|
|
732
|
+
if self._sessions_client:
|
|
733
|
+
await self._sessions_client.close()
|
|
734
|
+
if self._executor and self._executor.is_running:
|
|
735
|
+
await self._executor.stop()
|
|
736
|
+
|
|
737
|
+
self._state = RunState.STOPPED
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
async def run_agent(
|
|
741
|
+
project_id: str,
|
|
742
|
+
task_id: str | None = None,
|
|
743
|
+
working_directory: str | None = None,
|
|
744
|
+
api_key: str | None = None,
|
|
745
|
+
agent_type: str = "claude",
|
|
746
|
+
agent_name: str | None = None,
|
|
747
|
+
model: str | None = None,
|
|
748
|
+
max_turns: int | None = None,
|
|
749
|
+
max_tasks: int = 1,
|
|
750
|
+
timeout_seconds: int = 3600,
|
|
751
|
+
enable_worktrees: bool = False,
|
|
752
|
+
executor_config: ExecutorConfig | None = None,
|
|
753
|
+
workflow_id: str | None = None,
|
|
754
|
+
dry_run: bool = False,
|
|
755
|
+
) -> dict[str, Any]:
|
|
756
|
+
"""Run the steerdev agent.
|
|
757
|
+
|
|
758
|
+
Convenience function for running the agent with minimal setup.
|
|
759
|
+
|
|
760
|
+
Args:
|
|
761
|
+
project_id: SteerDev project ID.
|
|
762
|
+
task_id: Optional specific task ID to run.
|
|
763
|
+
working_directory: Directory to run the agent in.
|
|
764
|
+
api_key: API key for authentication.
|
|
765
|
+
agent_type: Type of agent to use.
|
|
766
|
+
agent_name: Name of the agent (creates agent if needed).
|
|
767
|
+
model: Model to use for the agent.
|
|
768
|
+
max_turns: Maximum number of agent turns per task.
|
|
769
|
+
max_tasks: Maximum number of tasks to process (0 for unlimited).
|
|
770
|
+
timeout_seconds: Timeout for the entire run.
|
|
771
|
+
enable_worktrees: Enable Claude CLI --worktree isolation.
|
|
772
|
+
executor_config: Executor configuration (tools, permissions).
|
|
773
|
+
workflow_id: Workflow ID for multi-phase execution.
|
|
774
|
+
dry_run: If True, print the command without executing it.
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
Run result metadata.
|
|
778
|
+
"""
|
|
779
|
+
runner = Runner(
|
|
780
|
+
project_id=project_id,
|
|
781
|
+
working_directory=working_directory,
|
|
782
|
+
api_key=api_key,
|
|
783
|
+
agent_type=agent_type,
|
|
784
|
+
agent_name=agent_name,
|
|
785
|
+
model=model,
|
|
786
|
+
max_turns=max_turns,
|
|
787
|
+
max_tasks=max_tasks,
|
|
788
|
+
timeout_seconds=timeout_seconds,
|
|
789
|
+
enable_worktrees=enable_worktrees,
|
|
790
|
+
executor_config=executor_config,
|
|
791
|
+
workflow_id=workflow_id,
|
|
792
|
+
dry_run=dry_run,
|
|
793
|
+
)
|
|
794
|
+
return await runner.run(task_id=task_id)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
async def resume_session(
|
|
798
|
+
session_id: str,
|
|
799
|
+
message: str,
|
|
800
|
+
api_key: str | None = None,
|
|
801
|
+
model: str | None = None,
|
|
802
|
+
dry_run: bool = False,
|
|
803
|
+
) -> dict[str, Any]:
|
|
804
|
+
"""Resume an existing session.
|
|
805
|
+
|
|
806
|
+
Args:
|
|
807
|
+
session_id: The session ID to resume.
|
|
808
|
+
message: The message to continue with.
|
|
809
|
+
api_key: API key for authentication.
|
|
810
|
+
model: Model to use for the agent.
|
|
811
|
+
dry_run: If True, print the command without executing it.
|
|
812
|
+
|
|
813
|
+
Returns:
|
|
814
|
+
Run result metadata.
|
|
815
|
+
"""
|
|
816
|
+
# Get session to find project_id
|
|
817
|
+
async with SessionsClient(api_key=api_key) as client:
|
|
818
|
+
session = await client.get_session(session_id)
|
|
819
|
+
if not session:
|
|
820
|
+
raise RunnerError(f"Session not found: {session_id}")
|
|
821
|
+
|
|
822
|
+
runner = Runner(
|
|
823
|
+
project_id=session.project_id,
|
|
824
|
+
working_directory=session.working_directory,
|
|
825
|
+
api_key=api_key,
|
|
826
|
+
model=model,
|
|
827
|
+
dry_run=dry_run,
|
|
828
|
+
)
|
|
829
|
+
return await runner.resume(session_id, message)
|