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
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Implementation Plan parser and display utilities.
|
|
2
|
+
|
|
3
|
+
Parses and displays structured implementation plans embedded in task descriptions.
|
|
4
|
+
Plans are stored as markdown between <!-- IMPLEMENTATION_PLAN_START --> and
|
|
5
|
+
<!-- IMPLEMENTATION_PLAN_END --> markers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.progress import Progress, BarColumn, TextColumn
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.tree import Tree
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
# Plan markers
|
|
21
|
+
PLAN_START_MARKER = "<!-- IMPLEMENTATION_PLAN_START -->"
|
|
22
|
+
PLAN_END_MARKER = "<!-- IMPLEMENTATION_PLAN_END -->"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileLocation:
|
|
27
|
+
"""File reference with optional line number."""
|
|
28
|
+
path: str
|
|
29
|
+
line_number: int | None = None
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
if self.line_number:
|
|
33
|
+
return f"{self.path}:{self.line_number}"
|
|
34
|
+
return self.path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PatternReference:
|
|
39
|
+
"""Pattern reference pointing to existing code."""
|
|
40
|
+
description: str
|
|
41
|
+
file: str | None = None
|
|
42
|
+
line_number: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ImplementationStep:
|
|
47
|
+
"""Single implementation step."""
|
|
48
|
+
order: int
|
|
49
|
+
description: str
|
|
50
|
+
completed: bool = False
|
|
51
|
+
file: FileLocation | None = None
|
|
52
|
+
pattern: PatternReference | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class FileReference:
|
|
57
|
+
"""File to be modified."""
|
|
58
|
+
path: str
|
|
59
|
+
file_type: str = "secondary" # primary, secondary, test
|
|
60
|
+
description: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class AcceptanceCriterion:
|
|
65
|
+
"""Acceptance criterion."""
|
|
66
|
+
description: str
|
|
67
|
+
completed: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class DependencyReference:
|
|
72
|
+
"""Dependency on another task."""
|
|
73
|
+
dep_type: str # blocked_by, blocks, related
|
|
74
|
+
task_identifier: str
|
|
75
|
+
title: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ImplementationPlan:
|
|
80
|
+
"""Complete implementation plan."""
|
|
81
|
+
steps: list[ImplementationStep] = field(default_factory=list)
|
|
82
|
+
files: list[FileReference] = field(default_factory=list)
|
|
83
|
+
patterns: list[PatternReference] = field(default_factory=list)
|
|
84
|
+
acceptance_criteria: list[AcceptanceCriterion] = field(default_factory=list)
|
|
85
|
+
dependencies: list[DependencyReference] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def steps_completed(self) -> int:
|
|
89
|
+
return sum(1 for s in self.steps if s.completed)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def criteria_completed(self) -> int:
|
|
93
|
+
return sum(1 for c in self.acceptance_criteria if c.completed)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def progress_percentage(self) -> int:
|
|
97
|
+
total = len(self.steps) + len(self.acceptance_criteria)
|
|
98
|
+
completed = self.steps_completed + self.criteria_completed
|
|
99
|
+
return int((completed / total * 100)) if total > 0 else 0
|
|
100
|
+
|
|
101
|
+
def is_empty(self) -> bool:
|
|
102
|
+
return (
|
|
103
|
+
len(self.steps) == 0 and
|
|
104
|
+
len(self.files) == 0 and
|
|
105
|
+
len(self.patterns) == 0 and
|
|
106
|
+
len(self.acceptance_criteria) == 0 and
|
|
107
|
+
len(self.dependencies) == 0
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_file_location(text: str) -> FileLocation | None:
|
|
112
|
+
"""Parse file location from text like `src/file.tsx:15`."""
|
|
113
|
+
match = re.search(r'`([^`]+)`', text)
|
|
114
|
+
if not match:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
parts = match.group(1).split(":")
|
|
118
|
+
path = parts[0]
|
|
119
|
+
line_number = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
|
120
|
+
return FileLocation(path=path, line_number=line_number)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_plan_markdown(description: str) -> str | None:
|
|
124
|
+
"""Extract implementation plan markdown from full description."""
|
|
125
|
+
start_idx = description.find(PLAN_START_MARKER)
|
|
126
|
+
end_idx = description.find(PLAN_END_MARKER)
|
|
127
|
+
|
|
128
|
+
if start_idx == -1 or end_idx == -1 or start_idx >= end_idx:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
return description[start_idx + len(PLAN_START_MARKER):end_idx].strip()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_task_description(description: str) -> str:
|
|
135
|
+
"""Extract task description (content before the plan markers)."""
|
|
136
|
+
start_idx = description.find(PLAN_START_MARKER)
|
|
137
|
+
if start_idx == -1:
|
|
138
|
+
return description.strip()
|
|
139
|
+
|
|
140
|
+
# Get content before plan, removing trailing separators
|
|
141
|
+
content = description[:start_idx].strip()
|
|
142
|
+
content = re.sub(r'\n---\s*$', '', content).strip()
|
|
143
|
+
return content
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_steps(plan_markdown: str) -> list[ImplementationStep]:
|
|
147
|
+
"""Parse steps section from plan markdown."""
|
|
148
|
+
steps: list[ImplementationStep] = []
|
|
149
|
+
|
|
150
|
+
# Find Steps section
|
|
151
|
+
steps_match = re.search(r'### Steps\s*\n([\s\S]*?)(?=\n### |$)', plan_markdown, re.IGNORECASE)
|
|
152
|
+
if not steps_match:
|
|
153
|
+
return steps
|
|
154
|
+
|
|
155
|
+
steps_content = steps_match.group(1)
|
|
156
|
+
|
|
157
|
+
# Match numbered list items with optional checkboxes
|
|
158
|
+
# Format: 1. [ ] Description
|
|
159
|
+
step_pattern = re.compile(r'^\d+\.\s*\[([ xX])\]\s*(.+?)(?=\n\d+\.\s*\[|$)', re.MULTILINE | re.DOTALL)
|
|
160
|
+
|
|
161
|
+
for order, match in enumerate(step_pattern.finditer(steps_content)):
|
|
162
|
+
completed = match.group(1).lower() == 'x'
|
|
163
|
+
step_block = match.group(2).strip()
|
|
164
|
+
|
|
165
|
+
# Extract main description (first line)
|
|
166
|
+
lines = step_block.split('\n')
|
|
167
|
+
description = lines[0].strip()
|
|
168
|
+
|
|
169
|
+
# Extract file reference
|
|
170
|
+
file_location = None
|
|
171
|
+
file_match = re.search(r'- File:\s*`([^`]+)`', step_block, re.IGNORECASE)
|
|
172
|
+
if file_match:
|
|
173
|
+
parts = file_match.group(1).split(":")
|
|
174
|
+
file_location = FileLocation(
|
|
175
|
+
path=parts[0],
|
|
176
|
+
line_number=int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Extract pattern reference
|
|
180
|
+
pattern = None
|
|
181
|
+
pattern_match = re.search(r'- Pattern:\s*(.+?)(?:\s*\(([^)]+)\))?$', step_block, re.IGNORECASE | re.MULTILINE)
|
|
182
|
+
if pattern_match:
|
|
183
|
+
pattern_desc = pattern_match.group(1).strip()
|
|
184
|
+
pattern_file_loc = _parse_file_location(pattern_desc)
|
|
185
|
+
pattern = PatternReference(
|
|
186
|
+
description=re.sub(r'`[^`]+`', '', pattern_desc).strip() or pattern_desc,
|
|
187
|
+
file=pattern_file_loc.path if pattern_file_loc else None,
|
|
188
|
+
line_number=pattern_file_loc.line_number if pattern_file_loc else None
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
steps.append(ImplementationStep(
|
|
192
|
+
order=order,
|
|
193
|
+
description=description,
|
|
194
|
+
completed=completed,
|
|
195
|
+
file=file_location,
|
|
196
|
+
pattern=pattern
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
return steps
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse_files(plan_markdown: str) -> list[FileReference]:
|
|
203
|
+
"""Parse files to modify section."""
|
|
204
|
+
files: list[FileReference] = []
|
|
205
|
+
|
|
206
|
+
files_match = re.search(r'### Files to Modify\s*\n([\s\S]*?)(?=\n### |$)', plan_markdown, re.IGNORECASE)
|
|
207
|
+
if not files_match:
|
|
208
|
+
return files
|
|
209
|
+
|
|
210
|
+
files_content = files_match.group(1)
|
|
211
|
+
|
|
212
|
+
# Match list items like "- `path/to/file.tsx` (primary)"
|
|
213
|
+
file_pattern = re.compile(r'^-\s*`([^`]+)`(?:\s*\(([^)]+)\))?(?:\s*-\s*(.+))?$', re.MULTILINE)
|
|
214
|
+
|
|
215
|
+
for match in file_pattern.finditer(files_content):
|
|
216
|
+
path = match.group(1)
|
|
217
|
+
type_hint = (match.group(2) or "").lower()
|
|
218
|
+
description = match.group(3).strip() if match.group(3) else None
|
|
219
|
+
|
|
220
|
+
file_type = "secondary"
|
|
221
|
+
if "primary" in type_hint or "main" in type_hint:
|
|
222
|
+
file_type = "primary"
|
|
223
|
+
elif "test" in type_hint:
|
|
224
|
+
file_type = "test"
|
|
225
|
+
|
|
226
|
+
files.append(FileReference(path=path, file_type=file_type, description=description))
|
|
227
|
+
|
|
228
|
+
return files
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _parse_patterns(plan_markdown: str) -> list[PatternReference]:
|
|
232
|
+
"""Parse patterns to follow section."""
|
|
233
|
+
patterns: list[PatternReference] = []
|
|
234
|
+
|
|
235
|
+
patterns_match = re.search(r'### Patterns to Follow\s*\n([\s\S]*?)(?=\n### |$)', plan_markdown, re.IGNORECASE)
|
|
236
|
+
if not patterns_match:
|
|
237
|
+
return patterns
|
|
238
|
+
|
|
239
|
+
patterns_content = patterns_match.group(1)
|
|
240
|
+
|
|
241
|
+
# Match list items
|
|
242
|
+
pattern_re = re.compile(r'^-\s*(.+)$', re.MULTILINE)
|
|
243
|
+
|
|
244
|
+
for match in pattern_re.finditer(patterns_content):
|
|
245
|
+
text = match.group(1).strip()
|
|
246
|
+
file_location = _parse_file_location(text)
|
|
247
|
+
|
|
248
|
+
patterns.append(PatternReference(
|
|
249
|
+
description=text,
|
|
250
|
+
file=file_location.path if file_location else None,
|
|
251
|
+
line_number=file_location.line_number if file_location else None
|
|
252
|
+
))
|
|
253
|
+
|
|
254
|
+
return patterns
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_acceptance_criteria(plan_markdown: str) -> list[AcceptanceCriterion]:
|
|
258
|
+
"""Parse acceptance criteria section."""
|
|
259
|
+
criteria: list[AcceptanceCriterion] = []
|
|
260
|
+
|
|
261
|
+
criteria_match = re.search(r'### Acceptance Criteria\s*\n([\s\S]*?)(?=\n### |$)', plan_markdown, re.IGNORECASE)
|
|
262
|
+
if not criteria_match:
|
|
263
|
+
return criteria
|
|
264
|
+
|
|
265
|
+
criteria_content = criteria_match.group(1)
|
|
266
|
+
|
|
267
|
+
# Match checkbox items
|
|
268
|
+
criterion_pattern = re.compile(r'^-\s*\[([ xX])\]\s*(.+)$', re.MULTILINE)
|
|
269
|
+
|
|
270
|
+
for match in criterion_pattern.finditer(criteria_content):
|
|
271
|
+
criteria.append(AcceptanceCriterion(
|
|
272
|
+
completed=match.group(1).lower() == 'x',
|
|
273
|
+
description=match.group(2).strip()
|
|
274
|
+
))
|
|
275
|
+
|
|
276
|
+
return criteria
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_dependencies(plan_markdown: str) -> list[DependencyReference]:
|
|
280
|
+
"""Parse dependencies section."""
|
|
281
|
+
dependencies: list[DependencyReference] = []
|
|
282
|
+
|
|
283
|
+
deps_match = re.search(r'### Dependencies\s*\n([\s\S]*?)(?=\n### |$)', plan_markdown, re.IGNORECASE)
|
|
284
|
+
if not deps_match:
|
|
285
|
+
return dependencies
|
|
286
|
+
|
|
287
|
+
deps_content = deps_match.group(1)
|
|
288
|
+
|
|
289
|
+
# Match "- Blocked by: TASK-123" or "- Blocks: TASK-456 (Some title)"
|
|
290
|
+
dep_pattern = re.compile(r'^-\s*(Blocked by|Blocks|Related to):\s*([A-Z]+-\d+)(?:\s*\(([^)]+)\))?', re.IGNORECASE | re.MULTILINE)
|
|
291
|
+
|
|
292
|
+
for match in dep_pattern.finditer(deps_content):
|
|
293
|
+
type_text = match.group(1).lower()
|
|
294
|
+
dep_type = "related"
|
|
295
|
+
if type_text == "blocked by":
|
|
296
|
+
dep_type = "blocked_by"
|
|
297
|
+
elif type_text == "blocks":
|
|
298
|
+
dep_type = "blocks"
|
|
299
|
+
|
|
300
|
+
dependencies.append(DependencyReference(
|
|
301
|
+
dep_type=dep_type,
|
|
302
|
+
task_identifier=match.group(2),
|
|
303
|
+
title=match.group(3).strip() if match.group(3) else None
|
|
304
|
+
))
|
|
305
|
+
|
|
306
|
+
return dependencies
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def parse_implementation_plan(description: str | None) -> ImplementationPlan | None:
|
|
310
|
+
"""Parse implementation plan from task description.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
description: Full task description/prompt that may contain an implementation plan.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Parsed ImplementationPlan or None if no plan found.
|
|
317
|
+
"""
|
|
318
|
+
if not description:
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
plan_markdown = extract_plan_markdown(description)
|
|
322
|
+
if not plan_markdown:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
return ImplementationPlan(
|
|
326
|
+
steps=_parse_steps(plan_markdown),
|
|
327
|
+
files=_parse_files(plan_markdown),
|
|
328
|
+
patterns=_parse_patterns(plan_markdown),
|
|
329
|
+
acceptance_criteria=_parse_acceptance_criteria(plan_markdown),
|
|
330
|
+
dependencies=_parse_dependencies(plan_markdown)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def display_implementation_plan(plan: ImplementationPlan) -> None:
|
|
335
|
+
"""Display implementation plan using rich formatting.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
plan: Parsed implementation plan to display.
|
|
339
|
+
"""
|
|
340
|
+
if plan.is_empty():
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
# Header with progress
|
|
344
|
+
progress_text = f"{plan.steps_completed}/{len(plan.steps)} steps"
|
|
345
|
+
if plan.acceptance_criteria:
|
|
346
|
+
progress_text += f", {plan.criteria_completed}/{len(plan.acceptance_criteria)} criteria"
|
|
347
|
+
progress_text += f" ({plan.progress_percentage}%)"
|
|
348
|
+
|
|
349
|
+
console.print(f"\n[bold blue]Implementation Plan[/bold blue] - {progress_text}")
|
|
350
|
+
console.print("─" * 60)
|
|
351
|
+
|
|
352
|
+
# Steps
|
|
353
|
+
if plan.steps:
|
|
354
|
+
console.print("\n[bold cyan]Steps:[/bold cyan]")
|
|
355
|
+
for step in plan.steps:
|
|
356
|
+
checkbox = "✓" if step.completed else "○"
|
|
357
|
+
style = "dim strikethrough" if step.completed else ""
|
|
358
|
+
console.print(f" [{checkbox}] [{style}]{step.order + 1}. {step.description}[/{style}]")
|
|
359
|
+
|
|
360
|
+
if step.file:
|
|
361
|
+
console.print(f" [dim]File: {step.file}[/dim]")
|
|
362
|
+
if step.pattern:
|
|
363
|
+
pattern_text = step.pattern.description
|
|
364
|
+
if step.pattern.file:
|
|
365
|
+
loc = FileLocation(step.pattern.file, step.pattern.line_number)
|
|
366
|
+
pattern_text += f" ({loc})"
|
|
367
|
+
console.print(f" [dim]Pattern: {pattern_text}[/dim]")
|
|
368
|
+
|
|
369
|
+
# Files
|
|
370
|
+
if plan.files:
|
|
371
|
+
console.print("\n[bold cyan]Files to Modify:[/bold cyan]")
|
|
372
|
+
for f in plan.files:
|
|
373
|
+
type_badge = f"[{f.file_type}]" if f.file_type != "secondary" else ""
|
|
374
|
+
desc = f" - {f.description}" if f.description else ""
|
|
375
|
+
console.print(f" • {f.path} {type_badge}{desc}")
|
|
376
|
+
|
|
377
|
+
# Patterns
|
|
378
|
+
if plan.patterns:
|
|
379
|
+
console.print("\n[bold cyan]Patterns to Follow:[/bold cyan]")
|
|
380
|
+
for p in plan.patterns:
|
|
381
|
+
loc_text = ""
|
|
382
|
+
if p.file:
|
|
383
|
+
loc = FileLocation(p.file, p.line_number)
|
|
384
|
+
loc_text = f" ({loc})"
|
|
385
|
+
console.print(f" • {p.description}{loc_text}")
|
|
386
|
+
|
|
387
|
+
# Acceptance Criteria
|
|
388
|
+
if plan.acceptance_criteria:
|
|
389
|
+
console.print("\n[bold cyan]Acceptance Criteria:[/bold cyan]")
|
|
390
|
+
for c in plan.acceptance_criteria:
|
|
391
|
+
checkbox = "✓" if c.completed else "○"
|
|
392
|
+
style = "dim strikethrough" if c.completed else ""
|
|
393
|
+
console.print(f" [{checkbox}] [{style}]{c.description}[/{style}]")
|
|
394
|
+
|
|
395
|
+
# Dependencies
|
|
396
|
+
if plan.dependencies:
|
|
397
|
+
console.print("\n[bold cyan]Dependencies:[/bold cyan]")
|
|
398
|
+
for d in plan.dependencies:
|
|
399
|
+
type_labels = {
|
|
400
|
+
"blocked_by": "[red]Blocked by[/red]",
|
|
401
|
+
"blocks": "[yellow]Blocks[/yellow]",
|
|
402
|
+
"related": "[dim]Related to[/dim]"
|
|
403
|
+
}
|
|
404
|
+
label = type_labels.get(d.dep_type, d.dep_type)
|
|
405
|
+
title_text = f" ({d.title})" if d.title else ""
|
|
406
|
+
console.print(f" • {label}: {d.task_identifier}{title_text}")
|
|
407
|
+
|
|
408
|
+
console.print()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Message API client for agent-to-user communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID # noqa: TC003 - Required at runtime by Pydantic
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from steerdev_agent.api.client import get_api_endpoint, get_api_key
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentMessage(BaseModel):
|
|
16
|
+
"""Message from user to agent."""
|
|
17
|
+
|
|
18
|
+
id: UUID
|
|
19
|
+
content: str
|
|
20
|
+
message_type: str # inject_stdin, agent_handle
|
|
21
|
+
status: str
|
|
22
|
+
run_id: UUID | None = None
|
|
23
|
+
user_id: str
|
|
24
|
+
created_at: str
|
|
25
|
+
expires_at: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MessageClientError(Exception):
|
|
29
|
+
"""Error communicating with the messages API."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MessageClient:
|
|
35
|
+
"""Async client for polling and acknowledging user messages.
|
|
36
|
+
|
|
37
|
+
This client allows agents to:
|
|
38
|
+
- Poll for pending messages from users
|
|
39
|
+
- Acknowledge message receipt
|
|
40
|
+
- Respond to messages (for agent_handle type)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
agent_id: str,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
api_endpoint: str | None = None,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Initialize the message client.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
agent_id: The agent ID to poll messages for.
|
|
54
|
+
api_key: API key for authentication.
|
|
55
|
+
api_endpoint: Base API endpoint.
|
|
56
|
+
timeout: Request timeout in seconds.
|
|
57
|
+
"""
|
|
58
|
+
self.agent_id = agent_id
|
|
59
|
+
self._api_key = api_key or get_api_key()
|
|
60
|
+
self._api_endpoint = api_endpoint or get_api_endpoint()
|
|
61
|
+
self._timeout = timeout
|
|
62
|
+
self._client: httpx.AsyncClient | None = None
|
|
63
|
+
|
|
64
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
65
|
+
"""Get or create the HTTP client."""
|
|
66
|
+
if self._client is None:
|
|
67
|
+
headers = {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"User-Agent": "steerdev/0.1.0",
|
|
70
|
+
}
|
|
71
|
+
if self._api_key:
|
|
72
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
73
|
+
|
|
74
|
+
self._client = httpx.AsyncClient(
|
|
75
|
+
headers=headers,
|
|
76
|
+
timeout=httpx.Timeout(self._timeout),
|
|
77
|
+
)
|
|
78
|
+
return self._client
|
|
79
|
+
|
|
80
|
+
async def close(self) -> None:
|
|
81
|
+
"""Close the HTTP client."""
|
|
82
|
+
if self._client:
|
|
83
|
+
await self._client.aclose()
|
|
84
|
+
self._client = None
|
|
85
|
+
|
|
86
|
+
async def poll_messages(
|
|
87
|
+
self,
|
|
88
|
+
run_id: str | None = None,
|
|
89
|
+
since: str | None = None,
|
|
90
|
+
limit: int = 10,
|
|
91
|
+
) -> list[AgentMessage]:
|
|
92
|
+
"""Poll for pending messages from users.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
run_id: Optional run ID to filter messages.
|
|
96
|
+
since: Optional ISO timestamp to get messages after.
|
|
97
|
+
limit: Maximum number of messages to return.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of pending messages.
|
|
101
|
+
"""
|
|
102
|
+
if not self._api_key:
|
|
103
|
+
logger.warning("No API key configured, skipping message poll")
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
client = await self._get_client()
|
|
108
|
+
|
|
109
|
+
params: dict[str, Any] = {
|
|
110
|
+
"status": "pending",
|
|
111
|
+
"limit": limit,
|
|
112
|
+
}
|
|
113
|
+
if run_id:
|
|
114
|
+
params["run_id"] = run_id
|
|
115
|
+
if since:
|
|
116
|
+
params["since"] = since
|
|
117
|
+
|
|
118
|
+
url = f"{self._api_endpoint}/agents/{self.agent_id}/messages"
|
|
119
|
+
response = await client.get(url, params=params)
|
|
120
|
+
|
|
121
|
+
if response.status_code == 200:
|
|
122
|
+
data = response.json()
|
|
123
|
+
messages = [AgentMessage(**msg) for msg in data.get("messages", [])]
|
|
124
|
+
if messages:
|
|
125
|
+
logger.debug(f"Received {len(messages)} pending messages")
|
|
126
|
+
return messages
|
|
127
|
+
elif response.status_code == 404:
|
|
128
|
+
logger.warning(f"Agent {self.agent_id} not found")
|
|
129
|
+
return []
|
|
130
|
+
else:
|
|
131
|
+
logger.error(f"Failed to poll messages: {response.status_code} - {response.text}")
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
except httpx.TimeoutException:
|
|
135
|
+
logger.warning("Timeout polling for messages")
|
|
136
|
+
return []
|
|
137
|
+
except httpx.HTTPError as e:
|
|
138
|
+
logger.error(f"HTTP error polling messages: {e}")
|
|
139
|
+
return []
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.exception(f"Unexpected error polling messages: {e}")
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
async def acknowledge_message(
|
|
145
|
+
self,
|
|
146
|
+
message_id: str | UUID,
|
|
147
|
+
status: str = "acknowledged",
|
|
148
|
+
response: str | None = None,
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""Acknowledge receipt of a message.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
message_id: The message ID to acknowledge.
|
|
154
|
+
status: The new status ("delivered" or "acknowledged").
|
|
155
|
+
response: Optional response text for agent_handle messages.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if acknowledged successfully.
|
|
159
|
+
"""
|
|
160
|
+
if not self._api_key:
|
|
161
|
+
logger.warning("No API key configured, skipping message acknowledge")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
client = await self._get_client()
|
|
166
|
+
|
|
167
|
+
payload: dict[str, Any] = {"status": status}
|
|
168
|
+
if response:
|
|
169
|
+
payload["response"] = response
|
|
170
|
+
|
|
171
|
+
url = f"{self._api_endpoint}/agents/{self.agent_id}/messages/{message_id}"
|
|
172
|
+
http_response = await client.patch(url, json=payload)
|
|
173
|
+
|
|
174
|
+
if http_response.status_code == 200:
|
|
175
|
+
logger.debug(f"Acknowledged message {message_id}")
|
|
176
|
+
return True
|
|
177
|
+
else:
|
|
178
|
+
logger.error(
|
|
179
|
+
f"Failed to acknowledge message: {http_response.status_code} - {http_response.text}"
|
|
180
|
+
)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
except httpx.TimeoutException:
|
|
184
|
+
logger.warning("Timeout acknowledging message")
|
|
185
|
+
return False
|
|
186
|
+
except httpx.HTTPError as e:
|
|
187
|
+
logger.error(f"HTTP error acknowledging message: {e}")
|
|
188
|
+
return False
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.exception(f"Unexpected error acknowledging message: {e}")
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
async def mark_delivered(self, message_id: str | UUID) -> bool:
|
|
194
|
+
"""Mark a message as delivered (received by agent).
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
message_id: The message ID.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if marked successfully.
|
|
201
|
+
"""
|
|
202
|
+
return await self.acknowledge_message(message_id, status="delivered")
|
|
203
|
+
|
|
204
|
+
async def mark_acknowledged(
|
|
205
|
+
self,
|
|
206
|
+
message_id: str | UUID,
|
|
207
|
+
response: str | None = None,
|
|
208
|
+
) -> bool:
|
|
209
|
+
"""Mark a message as acknowledged (processed by agent).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
message_id: The message ID.
|
|
213
|
+
response: Optional response for agent_handle messages.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
True if marked successfully.
|
|
217
|
+
"""
|
|
218
|
+
return await self.acknowledge_message(message_id, status="acknowledged", response=response)
|
|
219
|
+
|
|
220
|
+
async def __aenter__(self) -> MessageClient:
|
|
221
|
+
"""Enter async context manager."""
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
async def __aexit__(
|
|
225
|
+
self,
|
|
226
|
+
exc_type: type[BaseException] | None,
|
|
227
|
+
exc_val: BaseException | None,
|
|
228
|
+
exc_tb: Any,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Exit async context manager."""
|
|
231
|
+
await self.close()
|