fast-agent-mcp 0.3.15__py3-none-any.whl → 0.3.17__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.

Potentially problematic release.


This version of fast-agent-mcp might be problematic. Click here for more details.

Files changed (47) hide show
  1. fast_agent/__init__.py +2 -0
  2. fast_agent/agents/agent_types.py +5 -0
  3. fast_agent/agents/llm_agent.py +7 -0
  4. fast_agent/agents/llm_decorator.py +6 -0
  5. fast_agent/agents/mcp_agent.py +134 -10
  6. fast_agent/cli/__main__.py +35 -0
  7. fast_agent/cli/commands/check_config.py +85 -0
  8. fast_agent/cli/commands/go.py +100 -36
  9. fast_agent/cli/constants.py +15 -1
  10. fast_agent/cli/main.py +2 -1
  11. fast_agent/config.py +39 -10
  12. fast_agent/constants.py +8 -0
  13. fast_agent/context.py +24 -15
  14. fast_agent/core/direct_decorators.py +9 -0
  15. fast_agent/core/fastagent.py +101 -1
  16. fast_agent/core/logging/listeners.py +8 -0
  17. fast_agent/interfaces.py +12 -0
  18. fast_agent/llm/fastagent_llm.py +45 -0
  19. fast_agent/llm/memory.py +26 -1
  20. fast_agent/llm/model_database.py +4 -1
  21. fast_agent/llm/model_factory.py +4 -2
  22. fast_agent/llm/model_info.py +19 -43
  23. fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
  24. fast_agent/llm/provider/google/llm_google_native.py +238 -7
  25. fast_agent/llm/provider/openai/llm_openai.py +382 -19
  26. fast_agent/llm/provider/openai/responses.py +133 -0
  27. fast_agent/resources/setup/agent.py +2 -0
  28. fast_agent/resources/setup/fastagent.config.yaml +6 -0
  29. fast_agent/skills/__init__.py +9 -0
  30. fast_agent/skills/registry.py +208 -0
  31. fast_agent/tools/shell_runtime.py +404 -0
  32. fast_agent/ui/console_display.py +47 -996
  33. fast_agent/ui/elicitation_form.py +76 -24
  34. fast_agent/ui/elicitation_style.py +2 -2
  35. fast_agent/ui/enhanced_prompt.py +107 -37
  36. fast_agent/ui/history_display.py +20 -5
  37. fast_agent/ui/interactive_prompt.py +108 -3
  38. fast_agent/ui/markdown_helpers.py +104 -0
  39. fast_agent/ui/markdown_truncator.py +103 -45
  40. fast_agent/ui/message_primitives.py +50 -0
  41. fast_agent/ui/streaming.py +638 -0
  42. fast_agent/ui/tool_display.py +417 -0
  43. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/METADATA +8 -7
  44. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/RECORD +47 -39
  45. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/WHEEL +0 -0
  46. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/entry_points.txt +0 -0
  47. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from pathlib import Path
5
+ from typing import List, Sequence
6
+
7
+ import frontmatter
8
+
9
+ from fast_agent.core.logging.logger import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SkillManifest:
16
+ """Represents a single skill description loaded from SKILL.md."""
17
+
18
+ name: str
19
+ description: str
20
+ body: str
21
+ path: Path
22
+ relative_path: Path | None = None
23
+
24
+
25
+ class SkillRegistry:
26
+ """Simple registry that resolves a single skills directory and parses manifests."""
27
+
28
+ DEFAULT_CANDIDATES = (Path(".fast-agent/skills"), Path(".claude/skills"))
29
+
30
+ def __init__(
31
+ self, *, base_dir: Path | None = None, override_directory: Path | None = None
32
+ ) -> None:
33
+ self._base_dir = base_dir or Path.cwd()
34
+ self._directory: Path | None = None
35
+ self._override_failed: bool = False
36
+ self._errors: List[dict[str, str]] = []
37
+ if override_directory:
38
+ resolved = self._resolve_directory(override_directory)
39
+ if resolved and resolved.exists() and resolved.is_dir():
40
+ self._directory = resolved
41
+ else:
42
+ logger.warning(
43
+ "Skills directory override not found",
44
+ data={"directory": str(resolved)},
45
+ )
46
+ self._override_failed = True
47
+ if self._directory is None and not self._override_failed:
48
+ self._directory = self._find_default_directory()
49
+
50
+ @property
51
+ def directory(self) -> Path | None:
52
+ return self._directory
53
+
54
+ @property
55
+ def override_failed(self) -> bool:
56
+ return self._override_failed
57
+
58
+ def load_manifests(self) -> List[SkillManifest]:
59
+ self._errors = []
60
+ if not self._directory:
61
+ return []
62
+ return self._load_directory(self._directory, self._errors)
63
+
64
+ def load_manifests_with_errors(self) -> tuple[List[SkillManifest], List[dict[str, str]]]:
65
+ manifests = self.load_manifests()
66
+ return manifests, list(self._errors)
67
+
68
+ @property
69
+ def errors(self) -> List[dict[str, str]]:
70
+ return list(self._errors)
71
+
72
+ def _find_default_directory(self) -> Path | None:
73
+ for candidate in self.DEFAULT_CANDIDATES:
74
+ resolved = self._resolve_directory(candidate)
75
+ if resolved and resolved.exists() and resolved.is_dir():
76
+ return resolved
77
+ return None
78
+
79
+ def _resolve_directory(self, directory: Path) -> Path:
80
+ if directory.is_absolute():
81
+ return directory
82
+ return (self._base_dir / directory).resolve()
83
+
84
+ @classmethod
85
+ def load_directory(cls, directory: Path) -> List[SkillManifest]:
86
+ if not directory.exists() or not directory.is_dir():
87
+ logger.debug(
88
+ "Skills directory not found",
89
+ data={"directory": str(directory)},
90
+ )
91
+ return []
92
+ return cls._load_directory(directory)
93
+
94
+ @classmethod
95
+ def load_directory_with_errors(
96
+ cls, directory: Path
97
+ ) -> tuple[List[SkillManifest], List[dict[str, str]]]:
98
+ errors: List[dict[str, str]] = []
99
+ manifests = cls._load_directory(directory, errors)
100
+ return manifests, errors
101
+
102
+ @classmethod
103
+ def _load_directory(
104
+ cls,
105
+ directory: Path,
106
+ errors: List[dict[str, str]] | None = None,
107
+ ) -> List[SkillManifest]:
108
+ manifests: List[SkillManifest] = []
109
+ for entry in sorted(directory.iterdir()):
110
+ if not entry.is_dir():
111
+ continue
112
+ manifest_path = entry / "SKILL.md"
113
+ if not manifest_path.exists():
114
+ continue
115
+ manifest, error = cls._parse_manifest(manifest_path)
116
+ if manifest:
117
+ # Compute relative path from skills directory (not cwd)
118
+ # Old behavior: try both cwd and directory
119
+ # relative_path: Path | None = None
120
+ # for base in (cwd, directory):
121
+ # try:
122
+ # relative_path = manifest_path.relative_to(base)
123
+ # break
124
+ # except ValueError:
125
+ # continue
126
+
127
+ # New behavior: always relative to skills directory
128
+ try:
129
+ relative_path = manifest_path.relative_to(directory)
130
+ except ValueError:
131
+ relative_path = None
132
+
133
+ manifest = replace(manifest, relative_path=relative_path)
134
+ manifests.append(manifest)
135
+ elif errors is not None:
136
+ errors.append(
137
+ {
138
+ "path": str(manifest_path),
139
+ "error": error or "Failed to parse skill manifest",
140
+ }
141
+ )
142
+ return manifests
143
+
144
+ @classmethod
145
+ def _parse_manifest(cls, manifest_path: Path) -> tuple[SkillManifest | None, str | None]:
146
+ try:
147
+ post = frontmatter.loads(manifest_path.read_text(encoding="utf-8"))
148
+ except Exception as exc: # noqa: BLE001
149
+ logger.warning(
150
+ "Failed to parse skill manifest",
151
+ data={"path": str(manifest_path), "error": str(exc)},
152
+ )
153
+ return None, str(exc)
154
+
155
+ metadata = post.metadata or {}
156
+ name = metadata.get("name")
157
+ description = metadata.get("description")
158
+
159
+ if not isinstance(name, str) or not name.strip():
160
+ logger.warning("Skill manifest missing name", data={"path": str(manifest_path)})
161
+ return None, "Missing 'name' field"
162
+ if not isinstance(description, str) or not description.strip():
163
+ logger.warning("Skill manifest missing description", data={"path": str(manifest_path)})
164
+ return None, "Missing 'description' field"
165
+
166
+ body_text = (post.content or "").strip()
167
+
168
+ return SkillManifest(
169
+ name=name.strip(),
170
+ description=description.strip(),
171
+ body=body_text,
172
+ path=manifest_path,
173
+ ), None
174
+
175
+
176
+ def format_skills_for_prompt(manifests: Sequence[SkillManifest]) -> str:
177
+ """
178
+ Format a collection of skill manifests into an XML-style block suitable for system prompts.
179
+ """
180
+ if not manifests:
181
+ return ""
182
+
183
+ preamble = (
184
+ "Skills provide specialized capabilities and domain knowledge. Use a Skill if it seems in any way "
185
+ "relevant to the Users task, intent or would increase effectiveness. \n"
186
+ "The 'execute' tool gives you direct shell access to the current working directory (agent workspace) "
187
+ "and outputted files are visible to the User.\n"
188
+ "To use a Skill you must first read the SKILL.md file (use 'execute' tool).\n "
189
+ "Only use skills listed in <available_skills> below.\n\n"
190
+ )
191
+ formatted_parts: List[str] = []
192
+
193
+ for manifest in manifests:
194
+ description = (manifest.description or "").strip()
195
+ relative_path = manifest.relative_path
196
+ path_attr = f' path="{relative_path}"' if relative_path is not None else ""
197
+ if relative_path is None and manifest.path:
198
+ path_attr = f' path="{manifest.path}"'
199
+
200
+ block_lines: List[str] = [f'<agent-skill name="{manifest.name}"{path_attr}>']
201
+ if description:
202
+ block_lines.append(f"{description}")
203
+ block_lines.append("</agent-skill>")
204
+ formatted_parts.append("\n".join(block_lines))
205
+
206
+ return "".join(
207
+ (f"{preamble}<available_skills>\n", "\n".join(formatted_parts), "\n</available_skills>")
208
+ )
@@ -0,0 +1,404 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ from mcp.types import CallToolResult, TextContent, Tool
14
+
15
+ from fast_agent.ui import console
16
+ from fast_agent.ui.progress_display import progress_display
17
+
18
+
19
+ class ShellRuntime:
20
+ """Helper for managing the optional local shell execute tool."""
21
+
22
+ def __init__(
23
+ self,
24
+ activation_reason: str | None,
25
+ logger,
26
+ timeout_seconds: int = 90,
27
+ warning_interval_seconds: int = 30,
28
+ skills_directory: Path | None = None,
29
+ ) -> None:
30
+ self._activation_reason = activation_reason
31
+ self._logger = logger
32
+ self._timeout_seconds = timeout_seconds
33
+ self._warning_interval_seconds = warning_interval_seconds
34
+ self._skills_directory = skills_directory
35
+ self.enabled: bool = activation_reason is not None
36
+ self._tool: Tool | None = None
37
+
38
+ if self.enabled:
39
+ # Detect the shell early so we can include it in the tool description
40
+ runtime_info = self.runtime_info()
41
+ shell_name = runtime_info.get("name", "shell")
42
+
43
+ self._tool = Tool(
44
+ name="execute",
45
+ description=f"Run a shell command directly in {shell_name}.",
46
+ inputSchema={
47
+ "type": "object",
48
+ "properties": {
49
+ "command": {
50
+ "type": "string",
51
+ "description": "Command string only - no shell executable prefix (correct: 'pwd', incorrect: 'bash -c pwd').",
52
+ }
53
+ },
54
+ "required": ["command"],
55
+ "additionalProperties": False,
56
+ },
57
+ )
58
+
59
+ @property
60
+ def tool(self) -> Tool | None:
61
+ return self._tool
62
+
63
+ def announce(self) -> None:
64
+ """Inform the user why the local shell tool is active."""
65
+ if not self.enabled or not self._activation_reason:
66
+ return
67
+
68
+ message = f"Local shell execute tool enabled {self._activation_reason}."
69
+ self._logger.info(message)
70
+
71
+ def working_directory(self) -> Path:
72
+ """Return the working directory used for shell execution."""
73
+ # TODO -- reinstate when we provide duplication/isolation of skill workspaces
74
+ if self._skills_directory and self._skills_directory.exists():
75
+ return self._skills_directory
76
+ return Path.cwd()
77
+
78
+ def runtime_info(self) -> Dict[str, str | None]:
79
+ """Best-effort detection of the shell runtime used for local execution.
80
+
81
+ Uses modern Python APIs (platform.system(), shutil.which()) to detect
82
+ and prefer modern shells like pwsh (PowerShell 7+) and bash.
83
+ """
84
+ system = platform.system()
85
+
86
+ if system == "Windows":
87
+ # Preference order: pwsh > powershell > cmd
88
+ for shell_name in ["pwsh", "powershell", "cmd"]:
89
+ shell_path = shutil.which(shell_name)
90
+ if shell_path:
91
+ return {"name": shell_name, "path": shell_path}
92
+
93
+ # Fallback to COMSPEC if nothing found in PATH
94
+ comspec = os.environ.get("COMSPEC", "cmd.exe")
95
+ return {"name": Path(comspec).name, "path": comspec}
96
+ else:
97
+ # Unix-like: check SHELL env, then search for common shells
98
+ shell_env = os.environ.get("SHELL")
99
+ if shell_env and Path(shell_env).exists():
100
+ return {"name": Path(shell_env).name, "path": shell_env}
101
+
102
+ # Preference order: bash > zsh > sh
103
+ for shell_name in ["bash", "zsh", "sh"]:
104
+ shell_path = shutil.which(shell_name)
105
+ if shell_path:
106
+ return {"name": shell_name, "path": shell_path}
107
+
108
+ # Fallback to generic sh
109
+ return {"name": "sh", "path": None}
110
+
111
+ def metadata(self, command: Optional[str]) -> Dict[str, Any]:
112
+ """Build metadata for display when the shell tool is invoked."""
113
+ info = self.runtime_info()
114
+ working_dir = self.working_directory()
115
+ try:
116
+ working_dir_display = str(working_dir.relative_to(Path.cwd()))
117
+ except ValueError:
118
+ working_dir_display = str(working_dir)
119
+
120
+ return {
121
+ "variant": "shell",
122
+ "command": command,
123
+ "shell_name": info.get("name"),
124
+ "shell_path": info.get("path"),
125
+ "working_dir": str(working_dir),
126
+ "working_dir_display": working_dir_display,
127
+ "timeout_seconds": self._timeout_seconds,
128
+ "warning_interval_seconds": self._warning_interval_seconds,
129
+ "streams_output": True,
130
+ "returns_exit_code": True,
131
+ }
132
+
133
+ async def execute(self, arguments: Dict[str, Any] | None = None) -> CallToolResult:
134
+ """Execute a shell command and stream output to the console with timeout detection."""
135
+ command_value = (arguments or {}).get("command") if arguments else None
136
+ if not isinstance(command_value, str) or not command_value.strip():
137
+ return CallToolResult(
138
+ isError=True,
139
+ content=[
140
+ TextContent(
141
+ type="text",
142
+ text="The execute tool requires a 'command' string argument.",
143
+ )
144
+ ],
145
+ )
146
+
147
+ command = command_value.strip()
148
+ self._logger.debug(
149
+ f"Executing command with timeout={self._timeout_seconds}s, warning_interval={self._warning_interval_seconds}s"
150
+ )
151
+
152
+ # Pause progress display during shell execution to avoid overlaying output
153
+ with progress_display.paused():
154
+ try:
155
+ working_dir = self.working_directory()
156
+ runtime_details = self.runtime_info()
157
+ shell_name = (runtime_details.get("name") or "").lower()
158
+ shell_path = runtime_details.get("path")
159
+
160
+ # Detect platform for process group handling
161
+ is_windows = platform.system() == "Windows"
162
+
163
+ # Shared process kwargs
164
+ process_kwargs: dict[str, Any] = {
165
+ "stdout": asyncio.subprocess.PIPE,
166
+ "stderr": asyncio.subprocess.PIPE,
167
+ "cwd": working_dir,
168
+ }
169
+
170
+ if is_windows:
171
+ # Windows: CREATE_NEW_PROCESS_GROUP allows killing process tree
172
+ process_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
173
+ else:
174
+ # Unix: start_new_session creates new process group
175
+ process_kwargs["start_new_session"] = True
176
+
177
+ # Create the subprocess, preferring PowerShell on Windows when available
178
+ if is_windows and shell_path and shell_name in {"pwsh", "powershell"}:
179
+ process = await asyncio.create_subprocess_exec(
180
+ shell_path,
181
+ "-NoLogo",
182
+ "-NoProfile",
183
+ "-Command",
184
+ command,
185
+ **process_kwargs,
186
+ )
187
+ else:
188
+ if shell_path:
189
+ process_kwargs["executable"] = shell_path
190
+ process = await asyncio.create_subprocess_shell(
191
+ command,
192
+ **process_kwargs,
193
+ )
194
+
195
+ output_segments: list[str] = []
196
+ # Track last output time in a mutable container for sharing across coroutines
197
+ last_output_time = [time.time()]
198
+ timeout_occurred = [False]
199
+ watchdog_task = None
200
+
201
+ async def stream_output(
202
+ stream, style: Optional[str], is_stderr: bool = False
203
+ ) -> None:
204
+ if not stream:
205
+ return
206
+ while True:
207
+ line = await stream.readline()
208
+ if not line:
209
+ break
210
+ text = line.decode(errors="replace")
211
+ output_segments.append(text if not is_stderr else f"[stderr] {text}")
212
+ console.console.print(
213
+ text.rstrip("\n"),
214
+ style=style,
215
+ markup=False,
216
+ )
217
+ # Update last output time whenever we receive a line
218
+ last_output_time[0] = time.time()
219
+
220
+ async def watchdog() -> None:
221
+ """Monitor output timeout and emit warnings."""
222
+ last_warning_time = 0.0
223
+ self._logger.debug(
224
+ f"Watchdog started: timeout={self._timeout_seconds}s, warning_interval={self._warning_interval_seconds}s"
225
+ )
226
+
227
+ while True:
228
+ await asyncio.sleep(1) # Check every second
229
+
230
+ # Check if process has exited
231
+ if process.returncode is not None:
232
+ self._logger.debug("Watchdog: process exited normally")
233
+ break
234
+
235
+ elapsed = time.time() - last_output_time[0]
236
+ remaining = self._timeout_seconds - elapsed
237
+
238
+ # Emit warnings every warning_interval_seconds throughout execution
239
+ time_since_warning = elapsed - last_warning_time
240
+ if time_since_warning >= self._warning_interval_seconds and remaining > 0:
241
+ self._logger.debug(f"Watchdog: warning at {int(remaining)}s remaining")
242
+ console.console.print(
243
+ f"▶ No output detected - terminating in {int(remaining)}s",
244
+ style="black on red",
245
+ )
246
+ last_warning_time = elapsed
247
+
248
+ # Timeout exceeded
249
+ if elapsed >= self._timeout_seconds:
250
+ timeout_occurred[0] = True
251
+ self._logger.debug(
252
+ "Watchdog: timeout exceeded, terminating process group"
253
+ )
254
+ console.console.print(
255
+ "▶ Timeout exceeded - terminating process", style="black on red"
256
+ )
257
+ try:
258
+ if is_windows:
259
+ # Windows: try to signal the entire process group before terminating
260
+ try:
261
+ process.send_signal(signal.CTRL_BREAK_EVENT)
262
+ await asyncio.sleep(2)
263
+ except AttributeError:
264
+ # Older Python/asyncio may not support send_signal on Windows
265
+ self._logger.debug(
266
+ "Watchdog: CTRL_BREAK_EVENT unsupported, skipping"
267
+ )
268
+ except ValueError:
269
+ # Raised when no console is attached; fall back to terminate
270
+ self._logger.debug(
271
+ "Watchdog: no console attached for CTRL_BREAK_EVENT"
272
+ )
273
+ except ProcessLookupError:
274
+ pass # Process already exited
275
+
276
+ if process.returncode is None:
277
+ process.terminate()
278
+ await asyncio.sleep(2)
279
+ if process.returncode is None:
280
+ process.kill()
281
+ else:
282
+ # Unix: kill entire process group for clean cleanup
283
+ os.killpg(process.pid, signal.SIGTERM)
284
+ await asyncio.sleep(2)
285
+ if process.returncode is None:
286
+ os.killpg(process.pid, signal.SIGKILL)
287
+ except (ProcessLookupError, OSError):
288
+ pass # Process already terminated
289
+ except Exception as e:
290
+ self._logger.debug(f"Error terminating process: {e}")
291
+ # Fallback: kill just the main process
292
+ try:
293
+ process.kill()
294
+ except Exception:
295
+ pass
296
+ break
297
+
298
+ stdout_task = asyncio.create_task(stream_output(process.stdout, None))
299
+ stderr_task = asyncio.create_task(stream_output(process.stderr, "red", True))
300
+ watchdog_task = asyncio.create_task(watchdog())
301
+
302
+ # Wait for streams to complete
303
+ await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
304
+
305
+ # Cancel watchdog if still running
306
+ if watchdog_task and not watchdog_task.done():
307
+ watchdog_task.cancel()
308
+ try:
309
+ await watchdog_task
310
+ except asyncio.CancelledError:
311
+ pass
312
+
313
+ # Wait for process to finish
314
+ try:
315
+ return_code = await asyncio.wait_for(process.wait(), timeout=2.0)
316
+ except asyncio.TimeoutError:
317
+ # Process didn't exit, force kill
318
+ try:
319
+ if is_windows:
320
+ # Windows: force kill main process
321
+ process.kill()
322
+ else:
323
+ # Unix: SIGKILL to process group
324
+ os.killpg(process.pid, signal.SIGKILL)
325
+ return_code = await process.wait()
326
+ except Exception:
327
+ return_code = -1
328
+
329
+ # Build result based on timeout or normal completion
330
+ if timeout_occurred[0]:
331
+ combined_output = "".join(output_segments)
332
+ if combined_output and not combined_output.endswith("\n"):
333
+ combined_output += "\n"
334
+ combined_output += (
335
+ f"(timeout after {self._timeout_seconds}s - process terminated)"
336
+ )
337
+
338
+ result = CallToolResult(
339
+ isError=True,
340
+ content=[
341
+ TextContent(
342
+ type="text",
343
+ text=combined_output,
344
+ )
345
+ ],
346
+ )
347
+ else:
348
+ combined_output = "".join(output_segments)
349
+ # Add explicit exit code message for the LLM
350
+ if combined_output and not combined_output.endswith("\n"):
351
+ combined_output += "\n"
352
+ combined_output += f"process exit code was {return_code}"
353
+
354
+ result = CallToolResult(
355
+ isError=return_code != 0,
356
+ content=[
357
+ TextContent(
358
+ type="text",
359
+ text=combined_output,
360
+ )
361
+ ],
362
+ )
363
+
364
+ # Display bottom separator with exit code
365
+ try:
366
+ from rich.text import Text
367
+ except Exception: # pragma: no cover
368
+ Text = None # type: ignore[assignment]
369
+
370
+ if Text:
371
+ # Build bottom separator matching the style: ─| exit code 0 |─────────
372
+ width = console.console.size.width
373
+ exit_code_style = "red" if return_code != 0 else "dim"
374
+ exit_code_text = f"exit code {return_code}"
375
+
376
+ prefix = Text("─| ")
377
+ prefix.stylize("dim")
378
+ exit_text = Text(exit_code_text, style=exit_code_style)
379
+ suffix = Text(" |")
380
+ suffix.stylize("dim")
381
+
382
+ separator = Text()
383
+ separator.append_text(prefix)
384
+ separator.append_text(exit_text)
385
+ separator.append_text(suffix)
386
+ remaining = width - separator.cell_len
387
+ if remaining > 0:
388
+ separator.append("─" * remaining, style="dim")
389
+
390
+ console.console.print()
391
+ console.console.print(separator)
392
+ else:
393
+ console.console.print(f"exit code {return_code}", style="dim")
394
+
395
+ setattr(result, "_suppress_display", True)
396
+ setattr(result, "exit_code", return_code)
397
+ return result
398
+
399
+ except Exception as exc:
400
+ self._logger.error(f"Execute tool failed: {exc}")
401
+ return CallToolResult(
402
+ isError=True,
403
+ content=[TextContent(type="text", text=f"Command failed to start: {exc}")],
404
+ )