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/daemon.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""Persistent agent daemon mode.
|
|
2
|
+
|
|
3
|
+
Runs indefinitely, polling for commands from the API and falling back
|
|
4
|
+
to the task queue when idle. Each command/task execution creates a session
|
|
5
|
+
with full event streaming, reusing the existing Runner infrastructure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import signal
|
|
13
|
+
import socket
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from loguru import logger
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
|
|
21
|
+
from steerdev_agent.api.commands import CommandResponse, CommandsClient
|
|
22
|
+
from steerdev_agent.api.events import EventsClient
|
|
23
|
+
from steerdev_agent.api.sessions import SessionCreateRequest, SessionsClient
|
|
24
|
+
from steerdev_agent.api.tasks import TasksClient
|
|
25
|
+
from steerdev_agent.config.models import DaemonConfig, ExecutorConfig
|
|
26
|
+
from steerdev_agent.executor import ExecutorFactory
|
|
27
|
+
from steerdev_agent.executor.base import EventType
|
|
28
|
+
from steerdev_agent.executor.claude import ClaudeExecutorError
|
|
29
|
+
from steerdev_agent.prompt.builder import PromptBuilder, PromptContext, TaskContext
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DaemonError(Exception):
|
|
35
|
+
"""Error during daemon execution."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Daemon:
|
|
39
|
+
"""Persistent agent that polls for commands and executes them.
|
|
40
|
+
|
|
41
|
+
Lifecycle:
|
|
42
|
+
1. Register agent (get/create agent_id via API)
|
|
43
|
+
2. Recover stale commands from previous crash
|
|
44
|
+
3. Start heartbeat background task
|
|
45
|
+
4. Enter poll loop:
|
|
46
|
+
a. Poll /commands/next
|
|
47
|
+
b. If command found -> execute it
|
|
48
|
+
c. If no command AND auto_fetch_tasks -> fetch next task
|
|
49
|
+
d. If nothing -> sleep(idle_poll_interval)
|
|
50
|
+
e. Check for shutdown signal
|
|
51
|
+
5. On shutdown: send offline heartbeat, exit
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
project_id: str,
|
|
57
|
+
agent_name: str,
|
|
58
|
+
working_directory: str | Path | None = None,
|
|
59
|
+
api_key: str | None = None,
|
|
60
|
+
agent_type: str = "claude",
|
|
61
|
+
model: str | None = None,
|
|
62
|
+
max_turns: int | None = None,
|
|
63
|
+
daemon_config: DaemonConfig | None = None,
|
|
64
|
+
executor_config: ExecutorConfig | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.project_id = project_id
|
|
67
|
+
self.agent_name = agent_name
|
|
68
|
+
self.working_directory = Path(working_directory or Path.cwd())
|
|
69
|
+
self._api_key = api_key
|
|
70
|
+
self.agent_type = agent_type
|
|
71
|
+
self.model = model
|
|
72
|
+
self.max_turns = max_turns
|
|
73
|
+
self._daemon_config = daemon_config or DaemonConfig()
|
|
74
|
+
self._executor_config = executor_config or ExecutorConfig()
|
|
75
|
+
|
|
76
|
+
# State
|
|
77
|
+
self._agent_id: str | None = None
|
|
78
|
+
self._shutdown_event = asyncio.Event()
|
|
79
|
+
self._is_busy = False
|
|
80
|
+
self._consecutive_errors = 0
|
|
81
|
+
self._commands_executed = 0
|
|
82
|
+
self._commands_succeeded = 0
|
|
83
|
+
self._commands_failed = 0
|
|
84
|
+
self._tasks_executed = 0
|
|
85
|
+
|
|
86
|
+
# Components
|
|
87
|
+
self._commands_client: CommandsClient | None = None
|
|
88
|
+
self._sessions_client: SessionsClient | None = None
|
|
89
|
+
self._prompt_builder = PromptBuilder()
|
|
90
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
91
|
+
|
|
92
|
+
async def start(self) -> None:
|
|
93
|
+
"""Start the daemon loop."""
|
|
94
|
+
self._setup_signal_handlers()
|
|
95
|
+
|
|
96
|
+
console.print(
|
|
97
|
+
Panel(
|
|
98
|
+
f"[bold blue]SteerDev Daemon[/bold blue]\n"
|
|
99
|
+
f"Project: {self.project_id}\n"
|
|
100
|
+
f"Agent: {self.agent_name}\n"
|
|
101
|
+
f"Working Dir: {self.working_directory}\n"
|
|
102
|
+
f"Poll Interval: {self._daemon_config.poll_interval_seconds}s\n"
|
|
103
|
+
f"Auto-fetch Tasks: {self._daemon_config.auto_fetch_tasks}",
|
|
104
|
+
title="Starting Daemon",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
# Step 1: Register agent
|
|
110
|
+
self._agent_id = await self._register_agent()
|
|
111
|
+
if not self._agent_id:
|
|
112
|
+
raise DaemonError("Failed to register agent")
|
|
113
|
+
|
|
114
|
+
logger.info(f"Agent registered: {self._agent_id}")
|
|
115
|
+
|
|
116
|
+
# Initialize clients
|
|
117
|
+
self._commands_client = CommandsClient(
|
|
118
|
+
agent_id=self._agent_id,
|
|
119
|
+
api_key=self._api_key,
|
|
120
|
+
)
|
|
121
|
+
self._sessions_client = SessionsClient(api_key=self._api_key)
|
|
122
|
+
|
|
123
|
+
# Step 2: Recover stale commands
|
|
124
|
+
await self._recover_stale_commands()
|
|
125
|
+
|
|
126
|
+
# Step 3: Start heartbeat
|
|
127
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
128
|
+
|
|
129
|
+
# Step 4: Main poll loop
|
|
130
|
+
await self._poll_loop()
|
|
131
|
+
|
|
132
|
+
except DaemonError as e:
|
|
133
|
+
logger.error(f"Daemon error: {e}")
|
|
134
|
+
console.print(f"[red]Daemon error: {e}[/red]")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.exception(f"Unexpected daemon error: {e}")
|
|
137
|
+
console.print(f"[red]Unexpected error: {e}[/red]")
|
|
138
|
+
finally:
|
|
139
|
+
# Step 5: Shutdown
|
|
140
|
+
await self._shutdown()
|
|
141
|
+
|
|
142
|
+
async def _register_agent(self) -> str | None:
|
|
143
|
+
"""Register this agent via the API and return the agent_id."""
|
|
144
|
+
try:
|
|
145
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
146
|
+
from steerdev_agent.api.client import get_api_endpoint, get_api_key
|
|
147
|
+
|
|
148
|
+
api_key = self._api_key or get_api_key()
|
|
149
|
+
api_base = get_api_endpoint()
|
|
150
|
+
|
|
151
|
+
response = await client.post(
|
|
152
|
+
f"{api_base}/agents",
|
|
153
|
+
headers={
|
|
154
|
+
"Authorization": f"Bearer {api_key}",
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
},
|
|
157
|
+
json={
|
|
158
|
+
"name": self.agent_name,
|
|
159
|
+
"application": self.agent_type,
|
|
160
|
+
"hostname": socket.gethostname(),
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if response.status_code in (200, 201):
|
|
165
|
+
data = response.json()
|
|
166
|
+
return data["id"]
|
|
167
|
+
|
|
168
|
+
logger.error(f"Failed to register agent: {response.status_code} - {response.text}")
|
|
169
|
+
return None
|
|
170
|
+
except httpx.RequestError as e:
|
|
171
|
+
logger.error(f"Request error registering agent: {e}")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
async def _recover_stale_commands(self) -> None:
|
|
175
|
+
"""Mark any claimed/executing commands from a previous crash as failed."""
|
|
176
|
+
if not self._commands_client:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
for stale_status in ("claimed", "executing"):
|
|
180
|
+
result = await self._commands_client.list_commands(status=stale_status) # type: ignore[arg-type]
|
|
181
|
+
if not result:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
for cmd in result.commands:
|
|
185
|
+
logger.warning(f"Recovering stale command {cmd.id} (was {cmd.status})")
|
|
186
|
+
await self._commands_client.mark_failed(
|
|
187
|
+
cmd.id,
|
|
188
|
+
error=f"Recovered on daemon restart (was {cmd.status})",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def _poll_loop(self) -> None:
|
|
192
|
+
"""Main loop: poll for commands, execute them, or fall back to tasks."""
|
|
193
|
+
logger.info("Entering poll loop")
|
|
194
|
+
console.print("[green]Daemon ready, polling for commands...[/green]")
|
|
195
|
+
|
|
196
|
+
while not self._shutdown_event.is_set():
|
|
197
|
+
try:
|
|
198
|
+
executed = await self._poll_once()
|
|
199
|
+
|
|
200
|
+
if executed:
|
|
201
|
+
self._consecutive_errors = 0
|
|
202
|
+
interval = self._daemon_config.poll_interval_seconds
|
|
203
|
+
else:
|
|
204
|
+
interval = self._daemon_config.idle_poll_interval_seconds
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
self._consecutive_errors += 1
|
|
208
|
+
logger.error(f"Poll error ({self._consecutive_errors}): {e}")
|
|
209
|
+
|
|
210
|
+
if self._consecutive_errors >= self._daemon_config.max_consecutive_errors:
|
|
211
|
+
logger.error("Max consecutive errors reached, shutting down")
|
|
212
|
+
console.print("[red]Max consecutive errors reached, shutting down[/red]")
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
# Exponential backoff: 2^n seconds, capped at 60s
|
|
216
|
+
backoff = min(2**self._consecutive_errors, 60)
|
|
217
|
+
interval = backoff
|
|
218
|
+
|
|
219
|
+
# Sleep with cancellation support
|
|
220
|
+
try:
|
|
221
|
+
await asyncio.wait_for(
|
|
222
|
+
self._shutdown_event.wait(),
|
|
223
|
+
timeout=interval,
|
|
224
|
+
)
|
|
225
|
+
# If we get here, shutdown was signaled
|
|
226
|
+
break
|
|
227
|
+
except TimeoutError:
|
|
228
|
+
# Normal timeout, continue polling
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
async def _poll_once(self) -> bool:
|
|
232
|
+
"""Poll for one command or task. Returns True if something was executed."""
|
|
233
|
+
assert self._commands_client is not None
|
|
234
|
+
|
|
235
|
+
# Try command queue first
|
|
236
|
+
command = await self._commands_client.poll_next()
|
|
237
|
+
|
|
238
|
+
if command:
|
|
239
|
+
# Handle control commands specially
|
|
240
|
+
if command.command_type == "control":
|
|
241
|
+
return await self._handle_control_command(command)
|
|
242
|
+
|
|
243
|
+
await self._execute_command(command)
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
# Fall back to task queue if enabled
|
|
247
|
+
if self._daemon_config.auto_fetch_tasks:
|
|
248
|
+
executed = await self._execute_task_fallback()
|
|
249
|
+
if executed:
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
async def _handle_control_command(self, command: CommandResponse) -> bool:
|
|
255
|
+
"""Handle control commands (shutdown, etc.)."""
|
|
256
|
+
assert self._commands_client is not None
|
|
257
|
+
|
|
258
|
+
if command.control_action == "shutdown":
|
|
259
|
+
logger.info("Received shutdown command")
|
|
260
|
+
console.print("[yellow]Received shutdown command[/yellow]")
|
|
261
|
+
await self._commands_client.mark_completed(command.id, result="Shutdown acknowledged")
|
|
262
|
+
self._shutdown_event.set()
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
logger.warning(f"Unknown control action: {command.control_action}")
|
|
266
|
+
await self._commands_client.mark_failed(
|
|
267
|
+
command.id,
|
|
268
|
+
error=f"Unknown control action: {command.control_action}",
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
async def _execute_command(self, command: CommandResponse) -> None:
|
|
273
|
+
"""Execute a single command, creating a session and streaming events."""
|
|
274
|
+
assert self._commands_client is not None
|
|
275
|
+
assert self._sessions_client is not None
|
|
276
|
+
|
|
277
|
+
self._is_busy = True
|
|
278
|
+
self._commands_executed += 1
|
|
279
|
+
|
|
280
|
+
cmd_desc = (
|
|
281
|
+
f"task={command.task_id}"
|
|
282
|
+
if command.command_type == "task"
|
|
283
|
+
else f"prompt={command.prompt[:50]}..."
|
|
284
|
+
if command.prompt and len(command.prompt) > 50
|
|
285
|
+
else f"prompt={command.prompt}"
|
|
286
|
+
if command.prompt
|
|
287
|
+
else command.command_type
|
|
288
|
+
)
|
|
289
|
+
console.print(f"\n[bold cyan]Executing command: {cmd_desc}[/bold cyan]")
|
|
290
|
+
console.print(f"[dim]Command ID: {command.id}[/dim]")
|
|
291
|
+
|
|
292
|
+
# Build prompt based on command type
|
|
293
|
+
prompt = await self._build_prompt_for_command(command)
|
|
294
|
+
if not prompt:
|
|
295
|
+
await self._commands_client.mark_failed(command.id, error="Failed to build prompt")
|
|
296
|
+
self._commands_failed += 1
|
|
297
|
+
self._is_busy = False
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# Create session
|
|
301
|
+
session = await self._sessions_client.create_session(
|
|
302
|
+
SessionCreateRequest(
|
|
303
|
+
project_id=self.project_id,
|
|
304
|
+
task_id=command.task_id,
|
|
305
|
+
agent_name=self.agent_name,
|
|
306
|
+
agent_type=self.agent_type,
|
|
307
|
+
prompt=prompt,
|
|
308
|
+
working_directory=str(command.working_directory or self.working_directory),
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if not session:
|
|
313
|
+
await self._commands_client.mark_failed(command.id, error="Failed to create session")
|
|
314
|
+
self._commands_failed += 1
|
|
315
|
+
self._is_busy = False
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
# Mark command as executing with session_id
|
|
319
|
+
await self._commands_client.mark_executing(command.id, session.id)
|
|
320
|
+
await self._sessions_client.mark_running(session.id)
|
|
321
|
+
|
|
322
|
+
# Execute
|
|
323
|
+
events_client = EventsClient(session_id=session.id, api_key=self._api_key)
|
|
324
|
+
await events_client.start()
|
|
325
|
+
events_sent = 0
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
executor = ExecutorFactory.create(
|
|
329
|
+
config=self._executor_config,
|
|
330
|
+
working_directory=str(command.working_directory or self.working_directory),
|
|
331
|
+
model=self.model,
|
|
332
|
+
max_turns=self.max_turns,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Start with timeout
|
|
336
|
+
await executor.start(prompt)
|
|
337
|
+
console.print("[dim]Agent started, streaming output...[/dim]")
|
|
338
|
+
|
|
339
|
+
async def _run_execution() -> int:
|
|
340
|
+
nonlocal events_sent
|
|
341
|
+
async for event in executor.stream_events():
|
|
342
|
+
await events_client.add_event(
|
|
343
|
+
event_type=event.event_type.value,
|
|
344
|
+
data=event.data,
|
|
345
|
+
raw_json=event.raw_json,
|
|
346
|
+
timestamp=event.timestamp,
|
|
347
|
+
)
|
|
348
|
+
events_sent += 1
|
|
349
|
+
|
|
350
|
+
if event.event_type == EventType.ASSISTANT:
|
|
351
|
+
msg = event.data.get("message", {})
|
|
352
|
+
content = msg.get("content", "")
|
|
353
|
+
if isinstance(content, str) and content:
|
|
354
|
+
preview = content[:100] + "..." if len(content) > 100 else content
|
|
355
|
+
console.print(f"[cyan]Assistant:[/cyan] {preview}")
|
|
356
|
+
|
|
357
|
+
# Check for cancellation
|
|
358
|
+
if self._shutdown_event.is_set():
|
|
359
|
+
logger.info("Shutdown during execution, stopping executor")
|
|
360
|
+
await executor.stop()
|
|
361
|
+
return -1
|
|
362
|
+
|
|
363
|
+
return await executor.wait()
|
|
364
|
+
|
|
365
|
+
exit_code = await asyncio.wait_for(
|
|
366
|
+
_run_execution(),
|
|
367
|
+
timeout=self._daemon_config.command_timeout_seconds,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
agent_session_id = executor.session_id
|
|
371
|
+
|
|
372
|
+
if exit_code != 0:
|
|
373
|
+
stderr = await executor.get_stderr()
|
|
374
|
+
error_msg = stderr.strip() if stderr else f"Process exited with code {exit_code}"
|
|
375
|
+
await self._commands_client.mark_failed(command.id, error=error_msg)
|
|
376
|
+
await self._sessions_client.mark_failed(
|
|
377
|
+
session.id,
|
|
378
|
+
metadata={"error": error_msg, "exit_code": exit_code},
|
|
379
|
+
)
|
|
380
|
+
self._commands_failed += 1
|
|
381
|
+
console.print(f"[red]Command failed: {error_msg}[/red]")
|
|
382
|
+
else:
|
|
383
|
+
await self._commands_client.mark_completed(
|
|
384
|
+
command.id,
|
|
385
|
+
result=f"Completed successfully. Events: {events_sent}",
|
|
386
|
+
)
|
|
387
|
+
await self._sessions_client.mark_completed(
|
|
388
|
+
session.id,
|
|
389
|
+
agent_session_id=agent_session_id,
|
|
390
|
+
)
|
|
391
|
+
self._commands_succeeded += 1
|
|
392
|
+
console.print("[green]Command completed successfully[/green]")
|
|
393
|
+
|
|
394
|
+
except TimeoutError:
|
|
395
|
+
error_msg = f"Command timed out after {self._daemon_config.command_timeout_seconds}s"
|
|
396
|
+
logger.error(error_msg)
|
|
397
|
+
await self._commands_client.mark_failed(command.id, error=error_msg)
|
|
398
|
+
await self._sessions_client.mark_failed(session.id, metadata={"error": error_msg})
|
|
399
|
+
self._commands_failed += 1
|
|
400
|
+
console.print(f"[red]{error_msg}[/red]")
|
|
401
|
+
|
|
402
|
+
except ClaudeExecutorError as e:
|
|
403
|
+
error_msg = str(e)
|
|
404
|
+
logger.error(f"Executor error: {error_msg}")
|
|
405
|
+
await self._commands_client.mark_failed(command.id, error=error_msg)
|
|
406
|
+
await self._sessions_client.mark_failed(session.id, metadata={"error": error_msg})
|
|
407
|
+
self._commands_failed += 1
|
|
408
|
+
|
|
409
|
+
except Exception as e:
|
|
410
|
+
error_msg = str(e)
|
|
411
|
+
logger.exception(f"Unexpected error executing command: {error_msg}")
|
|
412
|
+
await self._commands_client.mark_failed(command.id, error=error_msg)
|
|
413
|
+
await self._sessions_client.mark_failed(session.id, metadata={"error": error_msg})
|
|
414
|
+
self._commands_failed += 1
|
|
415
|
+
|
|
416
|
+
finally:
|
|
417
|
+
await events_client.close()
|
|
418
|
+
self._is_busy = False
|
|
419
|
+
|
|
420
|
+
async def _build_prompt_for_command(self, command: CommandResponse) -> str | None:
|
|
421
|
+
"""Build a prompt string from a command."""
|
|
422
|
+
if command.command_type == "prompt":
|
|
423
|
+
return command.prompt
|
|
424
|
+
|
|
425
|
+
if command.command_type == "task":
|
|
426
|
+
if not command.task_id:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
# Fetch task details
|
|
430
|
+
with TasksClient(api_key=self._api_key) as client:
|
|
431
|
+
task = client.get_task(command.task_id)
|
|
432
|
+
if not task:
|
|
433
|
+
logger.error(f"Task not found: {command.task_id}")
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
context = PromptContext(
|
|
437
|
+
task=TaskContext(
|
|
438
|
+
id=task.get("id", ""),
|
|
439
|
+
title=task.get("title", "Unknown"),
|
|
440
|
+
prompt=task.get("prompt", ""),
|
|
441
|
+
status=task.get("status", "unstarted"),
|
|
442
|
+
priority=task.get("priority", 3),
|
|
443
|
+
working_directory=task.get("working_directory"),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
return self._prompt_builder.build(context)
|
|
447
|
+
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
async def _execute_task_fallback(self) -> bool:
|
|
451
|
+
"""Fetch and execute the next task from the task queue.
|
|
452
|
+
|
|
453
|
+
Returns True if a task was executed, False if none available.
|
|
454
|
+
"""
|
|
455
|
+
with TasksClient(api_key=self._api_key) as client:
|
|
456
|
+
task = client.get_next_task(project_id=self.project_id)
|
|
457
|
+
|
|
458
|
+
if not task:
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
task_id = task.get("id", "")
|
|
462
|
+
task_title = task.get("title", "Unknown")
|
|
463
|
+
|
|
464
|
+
console.print(f"\n[bold cyan]Auto-fetched task: {task_title}[/bold cyan]")
|
|
465
|
+
console.print(f"[dim]Task ID: {task_id}[/dim]")
|
|
466
|
+
|
|
467
|
+
self._is_busy = True
|
|
468
|
+
self._tasks_executed += 1
|
|
469
|
+
|
|
470
|
+
# Build prompt
|
|
471
|
+
context = PromptContext(
|
|
472
|
+
task=TaskContext(
|
|
473
|
+
id=task_id,
|
|
474
|
+
title=task_title,
|
|
475
|
+
prompt=task.get("prompt", ""),
|
|
476
|
+
status=task.get("status", "unstarted"),
|
|
477
|
+
priority=task.get("priority", 3),
|
|
478
|
+
working_directory=task.get("working_directory"),
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
prompt = self._prompt_builder.build(context)
|
|
482
|
+
|
|
483
|
+
# Create session
|
|
484
|
+
assert self._sessions_client is not None
|
|
485
|
+
session = await self._sessions_client.create_session(
|
|
486
|
+
SessionCreateRequest(
|
|
487
|
+
project_id=self.project_id,
|
|
488
|
+
task_id=task_id,
|
|
489
|
+
agent_name=self.agent_name,
|
|
490
|
+
agent_type=self.agent_type,
|
|
491
|
+
prompt=prompt,
|
|
492
|
+
working_directory=str(self.working_directory),
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if not session:
|
|
497
|
+
logger.error("Failed to create session for task fallback")
|
|
498
|
+
self._is_busy = False
|
|
499
|
+
return True # We tried, count as executed
|
|
500
|
+
|
|
501
|
+
await self._sessions_client.mark_running(session.id)
|
|
502
|
+
|
|
503
|
+
events_client = EventsClient(session_id=session.id, api_key=self._api_key)
|
|
504
|
+
await events_client.start()
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
executor = ExecutorFactory.create(
|
|
508
|
+
config=self._executor_config,
|
|
509
|
+
working_directory=str(self.working_directory),
|
|
510
|
+
model=self.model,
|
|
511
|
+
max_turns=self.max_turns,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
await executor.start(prompt)
|
|
515
|
+
|
|
516
|
+
async for event in executor.stream_events():
|
|
517
|
+
await events_client.add_event(
|
|
518
|
+
event_type=event.event_type.value,
|
|
519
|
+
data=event.data,
|
|
520
|
+
raw_json=event.raw_json,
|
|
521
|
+
timestamp=event.timestamp,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if event.event_type == EventType.ASSISTANT:
|
|
525
|
+
msg = event.data.get("message", {})
|
|
526
|
+
content = msg.get("content", "")
|
|
527
|
+
if isinstance(content, str) and content:
|
|
528
|
+
preview = content[:100] + "..." if len(content) > 100 else content
|
|
529
|
+
console.print(f"[cyan]Assistant:[/cyan] {preview}")
|
|
530
|
+
|
|
531
|
+
exit_code = await executor.wait()
|
|
532
|
+
agent_session_id = executor.session_id
|
|
533
|
+
|
|
534
|
+
if exit_code != 0:
|
|
535
|
+
stderr = await executor.get_stderr()
|
|
536
|
+
error_msg = stderr.strip() if stderr else f"Process exited with code {exit_code}"
|
|
537
|
+
await self._sessions_client.mark_failed(
|
|
538
|
+
session.id,
|
|
539
|
+
metadata={"error": error_msg, "exit_code": exit_code},
|
|
540
|
+
)
|
|
541
|
+
console.print(f"[red]Task failed: {error_msg}[/red]")
|
|
542
|
+
else:
|
|
543
|
+
await self._sessions_client.mark_completed(
|
|
544
|
+
session.id,
|
|
545
|
+
agent_session_id=agent_session_id,
|
|
546
|
+
)
|
|
547
|
+
console.print("[green]Task completed successfully[/green]")
|
|
548
|
+
|
|
549
|
+
except Exception as e:
|
|
550
|
+
logger.exception(f"Error executing task fallback: {e}")
|
|
551
|
+
await self._sessions_client.mark_failed(
|
|
552
|
+
session.id,
|
|
553
|
+
metadata={"error": str(e)},
|
|
554
|
+
)
|
|
555
|
+
finally:
|
|
556
|
+
await events_client.close()
|
|
557
|
+
self._is_busy = False
|
|
558
|
+
|
|
559
|
+
return True
|
|
560
|
+
|
|
561
|
+
async def _heartbeat_loop(self) -> None:
|
|
562
|
+
"""Background task: send heartbeat every N seconds."""
|
|
563
|
+
while not self._shutdown_event.is_set():
|
|
564
|
+
try:
|
|
565
|
+
await self._send_heartbeat()
|
|
566
|
+
except Exception as e:
|
|
567
|
+
logger.warning(f"Heartbeat error: {e}")
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
await asyncio.wait_for(
|
|
571
|
+
self._shutdown_event.wait(),
|
|
572
|
+
timeout=self._daemon_config.heartbeat_interval_seconds,
|
|
573
|
+
)
|
|
574
|
+
break # Shutdown signaled
|
|
575
|
+
except TimeoutError:
|
|
576
|
+
pass
|
|
577
|
+
|
|
578
|
+
async def _send_heartbeat(self) -> None:
|
|
579
|
+
"""Send a heartbeat to update agent status."""
|
|
580
|
+
try:
|
|
581
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
582
|
+
from steerdev_agent.api.client import get_api_endpoint, get_api_key
|
|
583
|
+
|
|
584
|
+
api_key = self._api_key or get_api_key()
|
|
585
|
+
api_base = get_api_endpoint()
|
|
586
|
+
|
|
587
|
+
# Re-register to update heartbeat (getOrCreateAgent updates last_heartbeat_at)
|
|
588
|
+
await client.post(
|
|
589
|
+
f"{api_base}/agents",
|
|
590
|
+
headers={
|
|
591
|
+
"Authorization": f"Bearer {api_key}",
|
|
592
|
+
"Content-Type": "application/json",
|
|
593
|
+
},
|
|
594
|
+
json={
|
|
595
|
+
"name": self.agent_name,
|
|
596
|
+
"application": self.agent_type,
|
|
597
|
+
"hostname": socket.gethostname(),
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
except httpx.RequestError as e:
|
|
601
|
+
logger.debug(f"Heartbeat request error: {e}")
|
|
602
|
+
|
|
603
|
+
def _setup_signal_handlers(self) -> None:
|
|
604
|
+
"""Set up SIGINT/SIGTERM handlers for graceful shutdown."""
|
|
605
|
+
loop = asyncio.get_running_loop()
|
|
606
|
+
|
|
607
|
+
def _signal_handler() -> None:
|
|
608
|
+
logger.info("Received shutdown signal")
|
|
609
|
+
console.print("\n[yellow]Received shutdown signal, finishing current work...[/yellow]")
|
|
610
|
+
self._shutdown_event.set()
|
|
611
|
+
|
|
612
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
613
|
+
loop.add_signal_handler(sig, _signal_handler)
|
|
614
|
+
|
|
615
|
+
async def _shutdown(self) -> None:
|
|
616
|
+
"""Clean up resources and send offline heartbeat."""
|
|
617
|
+
console.print("[dim]Shutting down daemon...[/dim]")
|
|
618
|
+
|
|
619
|
+
# Cancel heartbeat task
|
|
620
|
+
if self._heartbeat_task and not self._heartbeat_task.done():
|
|
621
|
+
self._heartbeat_task.cancel()
|
|
622
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
623
|
+
await self._heartbeat_task
|
|
624
|
+
|
|
625
|
+
# Send offline heartbeat (best effort)
|
|
626
|
+
with contextlib.suppress(Exception):
|
|
627
|
+
await self._send_heartbeat()
|
|
628
|
+
|
|
629
|
+
# Close clients
|
|
630
|
+
if self._commands_client:
|
|
631
|
+
await self._commands_client.close()
|
|
632
|
+
if self._sessions_client:
|
|
633
|
+
await self._sessions_client.close()
|
|
634
|
+
|
|
635
|
+
# Print summary
|
|
636
|
+
console.print(
|
|
637
|
+
Panel(
|
|
638
|
+
f"[bold]Daemon Summary[/bold]\n"
|
|
639
|
+
f"Commands Executed: {self._commands_executed}\n"
|
|
640
|
+
f"Commands Succeeded: {self._commands_succeeded}\n"
|
|
641
|
+
f"Commands Failed: {self._commands_failed}\n"
|
|
642
|
+
f"Tasks Auto-fetched: {self._tasks_executed}",
|
|
643
|
+
title="Shutdown Complete",
|
|
644
|
+
border_style="yellow",
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
async def run_daemon(
|
|
650
|
+
project_id: str,
|
|
651
|
+
agent_name: str,
|
|
652
|
+
working_directory: str | None = None,
|
|
653
|
+
api_key: str | None = None,
|
|
654
|
+
agent_type: str = "claude",
|
|
655
|
+
model: str | None = None,
|
|
656
|
+
max_turns: int | None = None,
|
|
657
|
+
daemon_config: DaemonConfig | None = None,
|
|
658
|
+
executor_config: ExecutorConfig | None = None,
|
|
659
|
+
) -> None:
|
|
660
|
+
"""Run the daemon.
|
|
661
|
+
|
|
662
|
+
Convenience function for running the daemon with minimal setup.
|
|
663
|
+
"""
|
|
664
|
+
daemon = Daemon(
|
|
665
|
+
project_id=project_id,
|
|
666
|
+
agent_name=agent_name,
|
|
667
|
+
working_directory=working_directory,
|
|
668
|
+
api_key=api_key,
|
|
669
|
+
agent_type=agent_type,
|
|
670
|
+
model=model,
|
|
671
|
+
max_turns=max_turns,
|
|
672
|
+
daemon_config=daemon_config,
|
|
673
|
+
executor_config=executor_config,
|
|
674
|
+
)
|
|
675
|
+
await daemon.start()
|