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.
Files changed (57) hide show
  1. steerdev-0.4.27.dist-info/METADATA +224 -0
  2. steerdev-0.4.27.dist-info/RECORD +57 -0
  3. steerdev-0.4.27.dist-info/WHEEL +4 -0
  4. steerdev-0.4.27.dist-info/entry_points.txt +2 -0
  5. steerdev_agent/__init__.py +10 -0
  6. steerdev_agent/api/__init__.py +32 -0
  7. steerdev_agent/api/activity.py +278 -0
  8. steerdev_agent/api/agents.py +145 -0
  9. steerdev_agent/api/client.py +158 -0
  10. steerdev_agent/api/commands.py +399 -0
  11. steerdev_agent/api/configs.py +238 -0
  12. steerdev_agent/api/context.py +306 -0
  13. steerdev_agent/api/events.py +294 -0
  14. steerdev_agent/api/hooks.py +178 -0
  15. steerdev_agent/api/implementation_plan.py +408 -0
  16. steerdev_agent/api/messages.py +231 -0
  17. steerdev_agent/api/prd.py +281 -0
  18. steerdev_agent/api/runs.py +526 -0
  19. steerdev_agent/api/sessions.py +403 -0
  20. steerdev_agent/api/specs.py +321 -0
  21. steerdev_agent/api/tasks.py +659 -0
  22. steerdev_agent/api/workflow_runs.py +351 -0
  23. steerdev_agent/api/workflows.py +191 -0
  24. steerdev_agent/cli.py +2254 -0
  25. steerdev_agent/config/__init__.py +19 -0
  26. steerdev_agent/config/models.py +236 -0
  27. steerdev_agent/config/platform.py +272 -0
  28. steerdev_agent/config/settings.py +62 -0
  29. steerdev_agent/daemon.py +675 -0
  30. steerdev_agent/executor/__init__.py +64 -0
  31. steerdev_agent/executor/base.py +121 -0
  32. steerdev_agent/executor/claude.py +328 -0
  33. steerdev_agent/executor/stream.py +163 -0
  34. steerdev_agent/git/__init__.py +1 -0
  35. steerdev_agent/handlers/__init__.py +5 -0
  36. steerdev_agent/handlers/prd.py +533 -0
  37. steerdev_agent/integration.py +334 -0
  38. steerdev_agent/prompt/__init__.py +10 -0
  39. steerdev_agent/prompt/builder.py +263 -0
  40. steerdev_agent/prompt/templates.py +422 -0
  41. steerdev_agent/py.typed +0 -0
  42. steerdev_agent/runner.py +829 -0
  43. steerdev_agent/setup/__init__.py +5 -0
  44. steerdev_agent/setup/claude_setup.py +560 -0
  45. steerdev_agent/setup/templates/claude_md_section.md +140 -0
  46. steerdev_agent/setup/templates/settings.json +69 -0
  47. steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
  48. steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
  49. steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
  50. steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
  51. steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
  52. steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
  53. steerdev_agent/setup/templates/steerdev.yaml +51 -0
  54. steerdev_agent/version.py +149 -0
  55. steerdev_agent/workflow/__init__.py +10 -0
  56. steerdev_agent/workflow/executor.py +494 -0
  57. steerdev_agent/workflow/memory.py +185 -0
@@ -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)