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,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()