minion-cli 1.0.0__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 (157) hide show
  1. minion/__init__.py +5 -0
  2. minion/__main__.py +3 -0
  3. minion/a2a/__init__.py +37 -0
  4. minion/a2a/card.py +69 -0
  5. minion/a2a/client.py +450 -0
  6. minion/a2a/config.py +95 -0
  7. minion/a2a/manager.py +116 -0
  8. minion/a2a/models.py +233 -0
  9. minion/a2a/server.py +454 -0
  10. minion/agents/__init__.py +89 -0
  11. minion/agents/builtin/coder.yaml +35 -0
  12. minion/agents/builtin/researcher.yaml +34 -0
  13. minion/agents/builtin/reviewer.yaml +33 -0
  14. minion/agents/builtin/tester.yaml +35 -0
  15. minion/agents/display.py +285 -0
  16. minion/agents/manifest.py +79 -0
  17. minion/agents/persist.py +28 -0
  18. minion/agents/registry.py +55 -0
  19. minion/agents/runner.py +253 -0
  20. minion/cli/__init__.py +3 -0
  21. minion/cli/_core.py +287 -0
  22. minion/cli/agents.py +60 -0
  23. minion/cli/config.py +39 -0
  24. minion/cli/doctor.py +82 -0
  25. minion/cli/mcp.py +54 -0
  26. minion/cli/memory.py +104 -0
  27. minion/cli/remote.py +88 -0
  28. minion/cli/skills.py +27 -0
  29. minion/compact/__init__.py +39 -0
  30. minion/compact/base.py +31 -0
  31. minion/compact/summary.py +113 -0
  32. minion/compact/truncate.py +58 -0
  33. minion/config/__init__.py +52 -0
  34. minion/config/file.py +422 -0
  35. minion/config/interactive.py +198 -0
  36. minion/config/model_catalog.py +165 -0
  37. minion/config/wizard.py +151 -0
  38. minion/context/__init__.py +11 -0
  39. minion/context/filetree.py +151 -0
  40. minion/context/manifest.py +248 -0
  41. minion/context/project.py +83 -0
  42. minion/context/prompts.py +106 -0
  43. minion/hooks/__init__.py +37 -0
  44. minion/hooks/builtin/__init__.py +3 -0
  45. minion/hooks/builtin/minion_md.py +53 -0
  46. minion/hooks/events.py +122 -0
  47. minion/hooks/handler.py +24 -0
  48. minion/hooks/handlers/__init__.py +3 -0
  49. minion/hooks/handlers/shell.py +76 -0
  50. minion/hooks/manifest.py +73 -0
  51. minion/hooks/persist.py +60 -0
  52. minion/hooks/registry.py +115 -0
  53. minion/hooks/result.py +14 -0
  54. minion/hooks/runner.py +119 -0
  55. minion/llm/__init__.py +4 -0
  56. minion/llm/anthropic.py +370 -0
  57. minion/llm/base.py +210 -0
  58. minion/llm/conversation.py +252 -0
  59. minion/llm/factory.py +78 -0
  60. minion/llm/openai.py +148 -0
  61. minion/llm/reflection.py +344 -0
  62. minion/llm/validate.py +70 -0
  63. minion/mcp/__init__.py +13 -0
  64. minion/mcp/config.py +124 -0
  65. minion/mcp/manager.py +766 -0
  66. minion/memory/__init__.py +13 -0
  67. minion/memory/config.py +42 -0
  68. minion/memory/embedder.py +68 -0
  69. minion/memory/extractor.py +231 -0
  70. minion/memory/injection.py +88 -0
  71. minion/memory/record.py +156 -0
  72. minion/memory/store.py +481 -0
  73. minion/memory/triggers.py +70 -0
  74. minion/memory/vector_store.py +123 -0
  75. minion/output/__init__.py +16 -0
  76. minion/output/base.py +202 -0
  77. minion/output/console.py +217 -0
  78. minion/output/diff.py +181 -0
  79. minion/output/display_utils.py +200 -0
  80. minion/output/formatter.py +154 -0
  81. minion/output/tui.py +199 -0
  82. minion/planner/__init__.py +10 -0
  83. minion/planner/creator.py +401 -0
  84. minion/planner/storage.py +54 -0
  85. minion/repl/__init__.py +22 -0
  86. minion/repl/agent_handlers.py +94 -0
  87. minion/repl/commands.py +659 -0
  88. minion/repl/config_cmd.py +271 -0
  89. minion/repl/init_md.py +70 -0
  90. minion/repl/input.py +168 -0
  91. minion/repl/mcp.py +188 -0
  92. minion/repl/session.py +1521 -0
  93. minion/repl/state.py +103 -0
  94. minion/runner/__init__.py +42 -0
  95. minion/runner/context.py +91 -0
  96. minion/runner/loop.py +616 -0
  97. minion/runner/parallel.py +318 -0
  98. minion/runner/session.py +172 -0
  99. minion/skills/__init__.py +12 -0
  100. minion/skills/builtin/commit.yaml +16 -0
  101. minion/skills/builtin/explain.yaml +27 -0
  102. minion/skills/builtin/refactor.yaml +33 -0
  103. minion/skills/builtin/review.yaml +28 -0
  104. minion/skills/builtin/test.yaml +24 -0
  105. minion/skills/manifest.py +80 -0
  106. minion/skills/persist.py +28 -0
  107. minion/skills/registry.py +76 -0
  108. minion/skills/runner.py +140 -0
  109. minion/theme/__init__.py +75 -0
  110. minion/theme/banner.py +297 -0
  111. minion/theme/console.py +31 -0
  112. minion/theme/palette.py +32 -0
  113. minion/theme/printers.py +295 -0
  114. minion/tools/__init__.py +1 -0
  115. minion/tools/confirmation.py +137 -0
  116. minion/tools/definitions.py +357 -0
  117. minion/tools/executor.py +903 -0
  118. minion/tools/implementations.py +549 -0
  119. minion/tools/outline.py +241 -0
  120. minion/tools/permissions.py +280 -0
  121. minion/tracing/__init__.py +12 -0
  122. minion/tracing/cli.py +161 -0
  123. minion/tracing/events.py +310 -0
  124. minion/tracing/server.py +85 -0
  125. minion/tracing/tracer.py +154 -0
  126. minion/tracing/ui.html +1166 -0
  127. minion/tui/__init__.py +46 -0
  128. minion/tui/agent_registry.py +96 -0
  129. minion/tui/app.py +1417 -0
  130. minion/tui/choice_panel.py +98 -0
  131. minion/tui/conversation.py +319 -0
  132. minion/tui/inspector.py +604 -0
  133. minion/tui/keys.py +20 -0
  134. minion/tui/messages.py +11 -0
  135. minion/tui/permission.py +188 -0
  136. minion/tui/render.py +141 -0
  137. minion/tui/screens/__init__.py +13 -0
  138. minion/tui/screens/agents_screen.py +2963 -0
  139. minion/tui/screens/base.py +373 -0
  140. minion/tui/screens/completion_setup.py +261 -0
  141. minion/tui/screens/config_panel.py +513 -0
  142. minion/tui/screens/help_screen.py +755 -0
  143. minion/tui/screens/hooks_screen.py +2367 -0
  144. minion/tui/screens/load_screen.py +528 -0
  145. minion/tui/screens/memories_screen.py +932 -0
  146. minion/tui/screens/model_config.py +930 -0
  147. minion/tui/screens/skills_screen.py +2682 -0
  148. minion/tui/setup_checklist.py +219 -0
  149. minion/tui/slots.py +180 -0
  150. minion/tui/status.py +144 -0
  151. minion/tui/terminal.py +92 -0
  152. minion/tui/theme.py +186 -0
  153. minion_cli-1.0.0.dist-info/METADATA +336 -0
  154. minion_cli-1.0.0.dist-info/RECORD +157 -0
  155. minion_cli-1.0.0.dist-info/WHEEL +4 -0
  156. minion_cli-1.0.0.dist-info/entry_points.txt +3 -0
  157. minion_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
minion/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ try:
2
+ from importlib.metadata import version as _version, PackageNotFoundError
3
+ __version__ = _version("minion-cli")
4
+ except PackageNotFoundError:
5
+ __version__ = "0.0.0-dev"
minion/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import _entry
2
+
3
+ _entry()
minion/a2a/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """A2A (Agent-to-Agent) protocol support for minion-cli.
2
+
3
+ Phase 11 — Global Domination.
4
+
5
+ Exports:
6
+ load_a2a_manager(cwd) — loads config, returns A2AManager
7
+ A2A_REMOTE_GUIDANCE — injected into system prompt when remote agents are configured
8
+ A2AManager — routes send_task() calls, emits traces
9
+ """
10
+
11
+ from .manager import A2AManager, load_a2a_manager
12
+
13
+ # Injected into the system prompt when a2a_manager has configured agents.
14
+ # Lists available agent names so the LLM knows what to pass to send_remote_task.
15
+ # Updated dynamically in runner.py based on the loaded agent names.
16
+ A2A_REMOTE_GUIDANCE = """\
17
+ ## Remote A2A Agents
18
+
19
+ You have access to the `send_remote_task` tool to delegate tasks to remote A2A agents.
20
+ Remote agents run independently on external systems and return their result as text.
21
+
22
+ Use `send_remote_task` when:
23
+ - A task benefits from a specialized remote agent or external infrastructure
24
+ - Tasks are genuinely independent and can run in parallel
25
+ - The remote agent has unique access or capabilities not available locally
26
+
27
+ Do NOT use `send_remote_task` for tasks your local tools can handle directly.
28
+
29
+ A "full context" task brief must include the goal, relevant file paths or code snippets,
30
+ and any constraints — the remote agent has zero knowledge of the current session.
31
+
32
+ If a remote agent returns an error or is unavailable, report the failure to the user and
33
+ offer to handle the task with local tools instead. Do not retry indefinitely.
34
+
35
+ Available agents are listed in the `send_remote_task` tool description."""
36
+
37
+ __all__ = ["A2AManager", "load_a2a_manager", "A2A_REMOTE_GUIDANCE"]
minion/a2a/card.py ADDED
@@ -0,0 +1,69 @@
1
+ """Agent Card generation for the A2A server.
2
+
3
+ The Agent Card is a JSON document served at /.well-known/agent.json that
4
+ advertises minion-cli's capabilities to external A2A orchestrators.
5
+ Generated dynamically at server startup so it always reflects the current
6
+ version, host, and port — no stale JSON file to maintain.
7
+ """
8
+
9
+ from .. import __version__
10
+ from .models import AgentCard
11
+
12
+
13
+ def generate_agent_card(host: str, port: int) -> AgentCard:
14
+ """Build an AgentCard for minion-cli's A2A server.
15
+
16
+ The card describes minion as a coding assistant capable of reading,
17
+ writing, analyzing code, running tests, and delegating to subagents.
18
+ Skills listed are coarse capability buckets — not exhaustive.
19
+ """
20
+ return AgentCard(
21
+ name="minion",
22
+ description=(
23
+ "Terminal agentic coding assistant with file I/O, shell execution, "
24
+ "code intelligence, reflection, memory, skills, and subagents."
25
+ ),
26
+ url=f"http://{host}:{port}",
27
+ version=__version__,
28
+ capabilities={
29
+ "streaming": True,
30
+ "pushNotifications": False,
31
+ "stateTransitionHistory": False,
32
+ },
33
+ default_input_modes=["text"],
34
+ default_output_modes=["text"],
35
+ skills=[
36
+ {
37
+ "id": "coding",
38
+ "name": "Coding Assistant",
39
+ "description": "Read, write, analyze, and refactor code files.",
40
+ "tags": ["code", "files"],
41
+ "inputModes": ["text"],
42
+ "outputModes": ["text"],
43
+ },
44
+ {
45
+ "id": "research",
46
+ "name": "Research",
47
+ "description": "Search and analyze codebases, find definitions and usages.",
48
+ "tags": ["search", "analysis"],
49
+ "inputModes": ["text"],
50
+ "outputModes": ["text"],
51
+ },
52
+ {
53
+ "id": "testing",
54
+ "name": "Testing",
55
+ "description": "Write, run, and diagnose test suites.",
56
+ "tags": ["tests", "quality"],
57
+ "inputModes": ["text"],
58
+ "outputModes": ["text"],
59
+ },
60
+ {
61
+ "id": "shell",
62
+ "name": "Shell Execution",
63
+ "description": "Run shell commands, build pipelines, check git status.",
64
+ "tags": ["shell", "git"],
65
+ "inputModes": ["text"],
66
+ "outputModes": ["text"],
67
+ },
68
+ ],
69
+ )
minion/a2a/client.py ADDED
@@ -0,0 +1,450 @@
1
+ """A2A HTTP client — sends tasks to a single remote A2A agent.
2
+
3
+ Sync path: uses http.client from stdlib (zero new deps).
4
+ Async path: uses httpx.AsyncClient (replaces time.sleep with asyncio.sleep).
5
+
6
+ Both paths handle spec-compliant input-required task state: when the server
7
+ needs human approval for a dangerous tool, the client surfaces the prompt and
8
+ sends a continuation via POST /tasks/send with {task_id, message: "yes"|"no"}.
9
+
10
+ Protocol subset implemented:
11
+ GET /.well-known/agent.json — fetch Agent Card
12
+ POST /tasks/send — submit task (or continue existing), poll until done
13
+ POST /tasks/sendSubscribe — submit task, read SSE stream until done
14
+ POST /tasks/send (continuation) — approve/deny input-required requests
15
+
16
+ Task lifecycle: submitted → working → [input-required → working →]* completed / failed
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import http.client
23
+ import json
24
+ import time
25
+ import urllib.parse
26
+ from dataclasses import dataclass, field
27
+ from typing import Callable, Iterator, Optional
28
+
29
+ from .models import AgentCard, Artifact, Task, TaskStatus, _make_message
30
+
31
+
32
+ # ── Minimal inline SSE parser ─────────────────────────────────────────────────
33
+
34
+ @dataclass
35
+ class _SSEEvent:
36
+ data: str = ""
37
+ event: str = ""
38
+ id: str = ""
39
+
40
+
41
+ def _iter_sse_events(response: http.client.HTTPResponse) -> Iterator[_SSEEvent]:
42
+ """Yield SSE events from an HTTP response using readline()."""
43
+ current = _SSEEvent()
44
+ while True:
45
+ line_bytes = response.readline()
46
+ if not line_bytes:
47
+ break
48
+ line = line_bytes.rstrip(b"\r\n").decode("utf-8", errors="replace")
49
+ if line == "":
50
+ if current.data:
51
+ yield current
52
+ current = _SSEEvent()
53
+ elif line.startswith("data:"):
54
+ current.data += line[5:].lstrip(" ")
55
+ elif line.startswith("event:"):
56
+ current.event = line[6:].strip()
57
+ elif line.startswith("id:"):
58
+ current.id = line[3:].strip()
59
+
60
+
61
+ class A2AError(Exception):
62
+ """Raised on HTTP errors, timeouts, or malformed A2A responses."""
63
+
64
+
65
+ # ── User approval UI ──────────────────────────────────────────────────────────
66
+
67
+ def _prompt_user_approval(agent_name: str, question: str, detail: str = "") -> bool:
68
+ """Interactively ask the user to approve/deny an input-required request.
69
+
70
+ Pauses any active spinner (e.g. the "waiting for agent..." status) so the
71
+ questionary prompt renders cleanly, then resumes the spinner after the user
72
+ answers so they know polling has continued.
73
+ """
74
+ from ..theme import console, pause_spinner, resume_spinner
75
+ pause_spinner()
76
+ try:
77
+ if detail:
78
+ console.print(f"\n[muted]{detail}[/]\n")
79
+ import questionary
80
+ from ..config import MINION_STYLE
81
+ return bool(
82
+ questionary.confirm(
83
+ f" [remote: {agent_name}] {question}",
84
+ default=False,
85
+ style=MINION_STYLE,
86
+ ).ask()
87
+ )
88
+ except Exception:
89
+ return False
90
+ finally:
91
+ resume_spinner()
92
+
93
+
94
+ def _extract_approval_parts(status_obj: dict) -> tuple[str, str]:
95
+ """Extract (question, detail) from an A2A status.message for input-required tasks.
96
+
97
+ Handles both the multi-part format (parts[0] = question, parts[1:] = context)
98
+ and external A2A servers that may embed everything in a single text part.
99
+ """
100
+ agent_msg = status_obj.get("message", {}) if isinstance(status_obj, dict) else {}
101
+ parts = agent_msg.get("parts", []) if isinstance(agent_msg, dict) else []
102
+ text_parts = [p.get("text", "") for p in parts if p.get("type") == "text" and p.get("text")]
103
+ question = text_parts[0] if text_parts else "Allow action?"
104
+ detail = "\n".join(text_parts[1:]) if len(text_parts) > 1 else ""
105
+ return question, detail
106
+
107
+
108
+ class A2AClient:
109
+ """HTTP client for one named remote A2A agent.
110
+
111
+ Stateless between calls — no persistent connection. Each send_task() /
112
+ fetch_agent_card() call opens a fresh connection and closes it when done.
113
+ """
114
+
115
+ def __init__(self, name: str, url: str, timeout_seconds: int = 60) -> None:
116
+ self.name = name
117
+ self._timeout = timeout_seconds
118
+ parsed = urllib.parse.urlparse(url)
119
+ self._scheme = parsed.scheme # "http" or "https"
120
+ self._netloc = parsed.netloc # "host:port" or "host"
121
+ self._base_path = parsed.path.rstrip("/") # "" or "/prefix"
122
+ self._base_url = url.rstrip("/")
123
+ # One contextId per client instance (i.e., per named remote agent per REPL session).
124
+ # All tasks sent to this agent share the context so the server can inject history.
125
+ import uuid as _uuid
126
+ self._context_id: str = str(_uuid.uuid4())
127
+
128
+ # ─── Connection factory ────────────────────────────────────────────────────
129
+
130
+ def _make_connection(self) -> http.client.HTTPConnection:
131
+ if self._scheme == "https":
132
+ return http.client.HTTPSConnection(self._netloc, timeout=self._timeout)
133
+ return http.client.HTTPConnection(self._netloc, timeout=self._timeout)
134
+
135
+ def _path(self, suffix: str) -> str:
136
+ return self._base_path + suffix
137
+
138
+ # ─── Agent Card ────────────────────────────────────────────────────────────
139
+
140
+ def fetch_agent_card(self) -> Optional[AgentCard]:
141
+ """GET /.well-known/agent.json → AgentCard, or None on failure."""
142
+ try:
143
+ conn = self._make_connection()
144
+ conn.request(
145
+ "GET",
146
+ self._path("/.well-known/agent.json"),
147
+ headers={"Accept": "application/json"},
148
+ )
149
+ resp = conn.getresponse()
150
+ if resp.status != 200:
151
+ return None
152
+ data = json.loads(resp.read().decode("utf-8"))
153
+ return AgentCard.from_dict(data)
154
+ except Exception:
155
+ return None
156
+
157
+ # ─── Task cancellation ────────────────────────────────────────────────────
158
+
159
+ def cancel_task(self, task_id: str) -> None:
160
+ """DELETE /tasks/{task_id} — request cancellation of a running task.
161
+
162
+ Best-effort: raises A2AError only on connection failure. A 404 (task not
163
+ found) or 409 (already in terminal state) is silently accepted.
164
+ """
165
+ try:
166
+ conn = self._make_connection()
167
+ conn.request(
168
+ "DELETE",
169
+ self._path(f"/tasks/{task_id}"),
170
+ headers={"Accept": "application/json"},
171
+ )
172
+ resp = conn.getresponse()
173
+ resp.read() # consume body
174
+ except Exception as e:
175
+ raise A2AError(f"Failed to cancel task '{task_id}' on '{self.name}': {e}") from e
176
+
177
+ # ─── Task submission (sync polling) ───────────────────────────────────────
178
+
179
+ def send_task(self, task_text: str) -> str:
180
+ """Submit a task and poll GET /tasks/{id} until completed or failed.
181
+
182
+ Returns artifact text (all artifacts joined with \\n\\n) on success.
183
+ Raises A2AError on HTTP failure, task failure, or timeout.
184
+ Handles input-required by prompting the user interactively.
185
+ """
186
+ task_id = self._submit_task(task_text)
187
+ task = self._poll_until_done(task_id)
188
+
189
+ if task.status == TaskStatus.FAILED:
190
+ raise A2AError(task.error or "Remote task failed with no error message.")
191
+
192
+ if task.artifacts:
193
+ return "\n\n".join(a.text for a in task.artifacts if a.text)
194
+ return ""
195
+
196
+ def _submit_task(self, task_text: str) -> str:
197
+ """POST /tasks/send → task_id."""
198
+ body = json.dumps({"contextId": self._context_id, "message": _make_message(task_text)}).encode("utf-8")
199
+ try:
200
+ conn = self._make_connection()
201
+ conn.request(
202
+ "POST",
203
+ self._path("/tasks/send"),
204
+ body=body,
205
+ headers={
206
+ "Content-Type": "application/json",
207
+ "Accept": "application/json",
208
+ "Content-Length": str(len(body)),
209
+ },
210
+ )
211
+ resp = conn.getresponse()
212
+ data = json.loads(resp.read().decode("utf-8"))
213
+ except Exception as e:
214
+ raise A2AError(f"Failed to submit task to '{self.name}': {e}") from e
215
+
216
+ if resp.status not in (200, 201, 202):
217
+ raise A2AError(
218
+ f"Task submission failed for '{self.name}': HTTP {resp.status}"
219
+ )
220
+
221
+ task_id = data.get("id")
222
+ if not task_id:
223
+ raise A2AError(f"No task ID in response from '{self.name}'")
224
+ return task_id
225
+
226
+ def _send_continuation(self, task_id: str, answer: str) -> None:
227
+ """POST /tasks/send with task id to continue an input-required task (spec: uses 'id' field)."""
228
+ body = json.dumps({"id": task_id, "message": _make_message(answer)}).encode("utf-8")
229
+ try:
230
+ conn = self._make_connection()
231
+ conn.request(
232
+ "POST",
233
+ self._path("/tasks/send"),
234
+ body=body,
235
+ headers={
236
+ "Content-Type": "application/json",
237
+ "Accept": "application/json",
238
+ "Content-Length": str(len(body)),
239
+ },
240
+ )
241
+ conn.getresponse()
242
+ except Exception:
243
+ pass # best-effort; poll loop will continue regardless
244
+
245
+ def _poll_until_done(self, task_id: str) -> Task:
246
+ """GET /tasks/{id} every 0.5s until status is completed or failed.
247
+
248
+ Handles input-required by prompting the user and sending a continuation.
249
+ """
250
+ deadline = time.monotonic() + self._timeout
251
+ while True:
252
+ if time.monotonic() >= deadline:
253
+ raise A2AError(
254
+ f"Task '{task_id}' from '{self.name}' timed out after {self._timeout}s"
255
+ )
256
+ try:
257
+ conn = self._make_connection()
258
+ conn.request(
259
+ "GET",
260
+ self._path(f"/tasks/{task_id}"),
261
+ headers={"Accept": "application/json"},
262
+ )
263
+ resp = conn.getresponse()
264
+ data = json.loads(resp.read().decode("utf-8"))
265
+ except A2AError:
266
+ raise
267
+ except Exception as e:
268
+ raise A2AError(f"Failed to poll task '{task_id}' from '{self.name}': {e}") from e
269
+
270
+ task = Task.from_dict(data)
271
+
272
+ if task.status == TaskStatus.INPUT_REQUIRED:
273
+ # Spec: prompt is in status.message (multi-part A2A Message)
274
+ status_obj = data.get("status", {})
275
+ question, detail = _extract_approval_parts(status_obj)
276
+ approved = _prompt_user_approval(self.name, question, detail)
277
+ self._send_continuation(task_id, "yes" if approved else "no")
278
+ time.sleep(0.5)
279
+ continue
280
+
281
+ if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELED):
282
+ return task
283
+ time.sleep(0.5)
284
+
285
+ # ─── Task submission (SSE streaming, sync) ────────────────────────────────
286
+
287
+ def send_task_streaming(
288
+ self,
289
+ task_text: str,
290
+ on_status: Optional[Callable[[str], None]] = None,
291
+ ) -> str:
292
+ """POST /tasks/sendSubscribe and read SSE stream until completion.
293
+
294
+ Handles input-required by prompting the user and sending a continuation,
295
+ then falls back to polling until the task resumes.
296
+ Calls on_status(status_str) for each SSE event received.
297
+ Returns the artifact text on success.
298
+ Raises A2AError on failure or timeout.
299
+ """
300
+ body = json.dumps({"contextId": self._context_id, "message": _make_message(task_text)}).encode("utf-8")
301
+ try:
302
+ conn = self._make_connection()
303
+ conn.request(
304
+ "POST",
305
+ self._path("/tasks/sendSubscribe"),
306
+ body=body,
307
+ headers={
308
+ "Content-Type": "application/json",
309
+ "Accept": "text/event-stream",
310
+ "Content-Length": str(len(body)),
311
+ },
312
+ )
313
+ resp = conn.getresponse()
314
+ except Exception as e:
315
+ raise A2AError(f"Failed to subscribe to task on '{self.name}': {e}") from e
316
+
317
+ if resp.status != 200:
318
+ raise A2AError(
319
+ f"Task subscribe failed for '{self.name}': HTTP {resp.status}"
320
+ )
321
+
322
+ task_id: Optional[str] = None
323
+ artifact_texts: list[str] = []
324
+
325
+ for sse_event in _iter_sse_events(resp):
326
+ try:
327
+ data = json.loads(sse_event.data)
328
+ except json.JSONDecodeError:
329
+ continue
330
+
331
+ task_id = data.get("id", task_id)
332
+
333
+ # Use SSE event type if present; fall back to payload key inspection.
334
+ # Spec event names: "task-artifact-update", "task-status-update"
335
+ event_type = sse_event.event # "" if server doesn't emit event names
336
+
337
+ is_artifact = event_type == "task-artifact-update" or (
338
+ not event_type and "artifact" in data
339
+ )
340
+ is_status = event_type == "task-status-update" or (
341
+ not event_type and "status" in data and "artifact" not in data
342
+ )
343
+
344
+ # TaskArtifactUpdateEvent: {"id", "artifact": {parts}, "final"}
345
+ if is_artifact and "artifact" in data:
346
+ artifact = Artifact.from_dict(data["artifact"])
347
+ if artifact.text:
348
+ artifact_texts.append(artifact.text)
349
+ if data.get("final"):
350
+ continue # wait for final status event
351
+
352
+ if not is_status:
353
+ continue
354
+
355
+ # TaskStatusUpdateEvent: {"id", "status": {state, timestamp}, "final"}
356
+ status_obj = data.get("status", {})
357
+ state = status_obj.get("state", "") if isinstance(status_obj, dict) else ""
358
+
359
+ if on_status is not None and state:
360
+ on_status(state)
361
+
362
+ if state == TaskStatus.INPUT_REQUIRED.value and task_id:
363
+ # Spec: prompt is in status.message (multi-part A2A Message)
364
+ question, detail = _extract_approval_parts(status_obj)
365
+ approved = _prompt_user_approval(self.name, question, detail)
366
+ self._send_continuation(task_id, "yes" if approved else "no")
367
+ task = self._poll_until_done(task_id)
368
+ if task.status == TaskStatus.FAILED:
369
+ raise A2AError(task.error or "Remote task failed.")
370
+ if task.artifacts:
371
+ return "\n\n".join(a.text for a in task.artifacts if a.text)
372
+ return ""
373
+
374
+ if state == TaskStatus.COMPLETED.value and data.get("final"):
375
+ return "\n\n".join(artifact_texts) if artifact_texts else ""
376
+
377
+ if state == TaskStatus.FAILED.value:
378
+ raise A2AError(data.get("error") or "Remote task failed.")
379
+
380
+ if state == TaskStatus.CANCELED.value:
381
+ raise A2AError(f"Task was canceled by '{self.name}'.")
382
+
383
+ raise A2AError(f"SSE stream from '{self.name}' ended without a final status event.")
384
+
385
+ # ─── Task submission (async with httpx) ───────────────────────────────────
386
+
387
+ async def send_task_async(self, task_text: str) -> str:
388
+ """Submit a task using httpx.AsyncClient with async polling.
389
+
390
+ Uses asyncio.sleep instead of time.sleep so the event loop stays responsive.
391
+ Handles input-required by prompting the user in a thread.
392
+ """
393
+ try:
394
+ import httpx
395
+ except ImportError:
396
+ raise A2AError("httpx is required for async A2A. Install it: pip install httpx") from None
397
+
398
+ async with httpx.AsyncClient(timeout=self._timeout, base_url=self._base_url) as client:
399
+ # Submit task with spec Message format
400
+ try:
401
+ resp = await client.post("/tasks/send", json={"contextId": self._context_id, "message": _make_message(task_text)})
402
+ resp.raise_for_status()
403
+ data = resp.json()
404
+ except httpx.HTTPError as e:
405
+ raise A2AError(f"Failed to submit task to '{self.name}': {e}") from e
406
+
407
+ task_id = data.get("id")
408
+ if not task_id:
409
+ raise A2AError(f"No task ID in response from '{self.name}'")
410
+
411
+ # Async poll loop
412
+ deadline = time.monotonic() + self._timeout
413
+ while True:
414
+ if time.monotonic() >= deadline:
415
+ raise A2AError(
416
+ f"Task '{task_id}' from '{self.name}' timed out after {self._timeout}s"
417
+ )
418
+ try:
419
+ poll_resp = await client.get(f"/tasks/{task_id}")
420
+ poll_resp.raise_for_status()
421
+ task_data = poll_resp.json()
422
+ except httpx.HTTPError as e:
423
+ raise A2AError(f"Failed to poll task '{task_id}': {e}") from e
424
+
425
+ task = Task.from_dict(task_data)
426
+
427
+ if task.status == TaskStatus.INPUT_REQUIRED:
428
+ # Spec: prompt is in status.message (multi-part A2A Message)
429
+ status_obj = task_data.get("status", {})
430
+ question, detail = _extract_approval_parts(status_obj)
431
+ approved = await asyncio.to_thread(_prompt_user_approval, self.name, question, detail)
432
+ answer = "yes" if approved else "no"
433
+ try:
434
+ await client.post("/tasks/send", json={"id": task_id, "message": _make_message(answer)})
435
+ except httpx.HTTPError:
436
+ pass # best-effort
437
+ await asyncio.sleep(0.5)
438
+ continue
439
+
440
+ if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELED):
441
+ break
442
+ await asyncio.sleep(0.5)
443
+
444
+ if task.status == TaskStatus.FAILED:
445
+ raise A2AError(task.error or "Remote task failed.")
446
+ if task.status == TaskStatus.CANCELED:
447
+ raise A2AError(f"Task was canceled by '{self.name}'.")
448
+ if task.artifacts:
449
+ return "\n\n".join(a.text for a in task.artifacts if a.text)
450
+ return ""
minion/a2a/config.py ADDED
@@ -0,0 +1,95 @@
1
+ """A2A agent configuration — loads ~/.minion/a2a.json and .minion/a2a.json.
2
+
3
+ Two-tier loading (user → project). Project agent names shadow user agent names.
4
+ If neither file exists, returns an empty dict — A2A is simply disabled.
5
+
6
+ Config format:
7
+ {
8
+ "agents": {
9
+ "remote_coder": {
10
+ "url": "http://localhost:8080",
11
+ "timeout_seconds": 60
12
+ },
13
+ "external_reviewer": {
14
+ "url": "https://reviewer.example.com",
15
+ "timeout_seconds": 120
16
+ }
17
+ }
18
+ }
19
+
20
+ Fields:
21
+ url — base URL of the remote A2A agent (required)
22
+ Agent Card served at <url>/.well-known/agent.json
23
+ timeout_seconds — request timeout in seconds (optional, default 60)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ from ..theme import console
33
+
34
+
35
+ @dataclass
36
+ class A2AAgentConfig:
37
+ name: str
38
+ url: str # base URL; agent card at url/.well-known/agent.json
39
+ timeout_seconds: int = 60
40
+
41
+
42
+ def load_a2a_config(cwd: Path | None = None) -> dict[str, A2AAgentConfig]:
43
+ """Load A2A agent configs from user and project tiers.
44
+
45
+ Returns dict[agent_name, A2AAgentConfig]. Project tier shadows user tier
46
+ on name collision. Returns {} if no config files found.
47
+
48
+ Args:
49
+ cwd: Project root for resolving .minion/a2a.json. Defaults to Path.cwd().
50
+ """
51
+ project_root = cwd or Path.cwd()
52
+ tiers = [
53
+ Path.home() / ".minion" / "a2a.json",
54
+ project_root / ".minion" / "a2a.json",
55
+ ]
56
+
57
+ configs: dict[str, A2AAgentConfig] = {}
58
+ for path in tiers:
59
+ if not path.exists():
60
+ continue
61
+ try:
62
+ raw = json.loads(path.read_text(encoding="utf-8"))
63
+ except json.JSONDecodeError as e:
64
+ console.print(f"[muted]Warning: skipping malformed {path}: {e}[/]")
65
+ continue
66
+
67
+ agents = raw.get("agents", {})
68
+ if not isinstance(agents, dict):
69
+ console.print(f"[muted]Warning: 'agents' in {path} must be an object — skipping.[/]")
70
+ continue
71
+
72
+ for name, spec in agents.items():
73
+ if not isinstance(spec, dict):
74
+ console.print(f"[muted]Warning: agent '{name}' in {path} must be an object — skipping.[/]")
75
+ continue
76
+
77
+ url = spec.get("url", "")
78
+ if not url or not isinstance(url, str) or not url.startswith(("http://", "https://")):
79
+ console.print(
80
+ f"[muted]Warning: agent '{name}' in {path} has invalid 'url' "
81
+ f"(must start with http:// or https://) — skipping.[/]"
82
+ )
83
+ continue
84
+
85
+ timeout = spec.get("timeout_seconds", 60)
86
+ if not isinstance(timeout, int) or timeout <= 0:
87
+ timeout = 60
88
+
89
+ configs[name] = A2AAgentConfig(
90
+ name=name,
91
+ url=url.rstrip("/"),
92
+ timeout_seconds=timeout,
93
+ )
94
+
95
+ return configs