fast-agent-mcp 0.3.15__py3-none-any.whl → 0.3.16__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.
- fast_agent/__init__.py +2 -0
- fast_agent/agents/agent_types.py +5 -0
- fast_agent/agents/llm_agent.py +7 -0
- fast_agent/agents/llm_decorator.py +6 -0
- fast_agent/agents/mcp_agent.py +134 -10
- fast_agent/cli/__main__.py +35 -0
- fast_agent/cli/commands/check_config.py +85 -0
- fast_agent/cli/commands/go.py +100 -36
- fast_agent/cli/constants.py +13 -1
- fast_agent/cli/main.py +1 -0
- fast_agent/config.py +39 -10
- fast_agent/constants.py +8 -0
- fast_agent/context.py +24 -15
- fast_agent/core/direct_decorators.py +9 -0
- fast_agent/core/fastagent.py +101 -1
- fast_agent/core/logging/listeners.py +8 -0
- fast_agent/interfaces.py +8 -0
- fast_agent/llm/fastagent_llm.py +45 -0
- fast_agent/llm/memory.py +26 -1
- fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
- fast_agent/llm/provider/openai/llm_openai.py +184 -18
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/resources/setup/agent.py +2 -0
- fast_agent/resources/setup/fastagent.config.yaml +6 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +200 -0
- fast_agent/tools/shell_runtime.py +404 -0
- fast_agent/ui/console_display.py +396 -129
- fast_agent/ui/elicitation_form.py +76 -24
- fast_agent/ui/elicitation_style.py +2 -2
- fast_agent/ui/enhanced_prompt.py +81 -25
- fast_agent/ui/history_display.py +20 -5
- fast_agent/ui/interactive_prompt.py +108 -3
- fast_agent/ui/markdown_truncator.py +1 -1
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/METADATA +8 -7
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/RECORD +39 -35
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/licenses/LICENSE +0 -0
|
@@ -20,6 +20,12 @@ mcp_timeline:
|
|
|
20
20
|
steps: 20 # number of timeline buckets to render
|
|
21
21
|
step_seconds: 15 # seconds per bucket (accepts values like "45s", "2m")
|
|
22
22
|
|
|
23
|
+
#shell_execution:
|
|
24
|
+
# length of time before terminating subprocess
|
|
25
|
+
# timeout_seconds: 20
|
|
26
|
+
# warning interval if no output seen
|
|
27
|
+
# warning_seconds: 5
|
|
28
|
+
|
|
23
29
|
# Logging and Console Configuration:
|
|
24
30
|
logger:
|
|
25
31
|
# level: "debug" | "info" | "warning" | "error"
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
cwd = Path.cwd()
|
|
110
|
+
for entry in sorted(directory.iterdir()):
|
|
111
|
+
if not entry.is_dir():
|
|
112
|
+
continue
|
|
113
|
+
manifest_path = entry / "SKILL.md"
|
|
114
|
+
if not manifest_path.exists():
|
|
115
|
+
continue
|
|
116
|
+
manifest, error = cls._parse_manifest(manifest_path)
|
|
117
|
+
if manifest:
|
|
118
|
+
relative_path: Path | None = None
|
|
119
|
+
for base in (cwd, directory):
|
|
120
|
+
try:
|
|
121
|
+
relative_path = manifest_path.relative_to(base)
|
|
122
|
+
break
|
|
123
|
+
except ValueError:
|
|
124
|
+
continue
|
|
125
|
+
manifest = replace(manifest, relative_path=relative_path)
|
|
126
|
+
manifests.append(manifest)
|
|
127
|
+
elif errors is not None:
|
|
128
|
+
errors.append(
|
|
129
|
+
{
|
|
130
|
+
"path": str(manifest_path),
|
|
131
|
+
"error": error or "Failed to parse skill manifest",
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
return manifests
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def _parse_manifest(cls, manifest_path: Path) -> tuple[SkillManifest | None, str | None]:
|
|
138
|
+
try:
|
|
139
|
+
post = frontmatter.loads(manifest_path.read_text(encoding="utf-8"))
|
|
140
|
+
except Exception as exc: # noqa: BLE001
|
|
141
|
+
logger.warning(
|
|
142
|
+
"Failed to parse skill manifest",
|
|
143
|
+
data={"path": str(manifest_path), "error": str(exc)},
|
|
144
|
+
)
|
|
145
|
+
return None, str(exc)
|
|
146
|
+
|
|
147
|
+
metadata = post.metadata or {}
|
|
148
|
+
name = metadata.get("name")
|
|
149
|
+
description = metadata.get("description")
|
|
150
|
+
|
|
151
|
+
if not isinstance(name, str) or not name.strip():
|
|
152
|
+
logger.warning("Skill manifest missing name", data={"path": str(manifest_path)})
|
|
153
|
+
return None, "Missing 'name' field"
|
|
154
|
+
if not isinstance(description, str) or not description.strip():
|
|
155
|
+
logger.warning("Skill manifest missing description", data={"path": str(manifest_path)})
|
|
156
|
+
return None, "Missing 'description' field"
|
|
157
|
+
|
|
158
|
+
body_text = (post.content or "").strip()
|
|
159
|
+
|
|
160
|
+
return SkillManifest(
|
|
161
|
+
name=name.strip(),
|
|
162
|
+
description=description.strip(),
|
|
163
|
+
body=body_text,
|
|
164
|
+
path=manifest_path,
|
|
165
|
+
), None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def format_skills_for_prompt(manifests: Sequence[SkillManifest]) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Format a collection of skill manifests into an XML-style block suitable for system prompts.
|
|
171
|
+
"""
|
|
172
|
+
if not manifests:
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
preamble = (
|
|
176
|
+
"Skills provide specialized capabilities and domain knowledge. Use a Skill if it seems in any way "
|
|
177
|
+
"relevant to the Users task, intent or would increase effectiveness. \n"
|
|
178
|
+
"The 'execute' tool gives you shell access to the current working directory (agent workspace) "
|
|
179
|
+
"and outputted files are visible to the User.\n"
|
|
180
|
+
"To use a Skill you must first read the SKILL.md file (use 'execute' tool).\n "
|
|
181
|
+
"Only use skills listed in <available_skills> below.\n\n"
|
|
182
|
+
)
|
|
183
|
+
formatted_parts: List[str] = []
|
|
184
|
+
|
|
185
|
+
for manifest in manifests:
|
|
186
|
+
description = (manifest.description or "").strip()
|
|
187
|
+
relative_path = manifest.relative_path
|
|
188
|
+
path_attr = f' path="{relative_path}"' if relative_path is not None else ""
|
|
189
|
+
if relative_path is None and manifest.path:
|
|
190
|
+
path_attr = f' path="{manifest.path}"'
|
|
191
|
+
|
|
192
|
+
block_lines: List[str] = [f'<agent-skill name="{manifest.name}"{path_attr}>']
|
|
193
|
+
if description:
|
|
194
|
+
block_lines.append(f"{description}")
|
|
195
|
+
block_lines.append("</agent-skill>")
|
|
196
|
+
formatted_parts.append("\n".join(block_lines))
|
|
197
|
+
|
|
198
|
+
return "".join(
|
|
199
|
+
(f"{preamble}<available_skills>\n", "\n".join(formatted_parts), "\n</available_skills>")
|
|
200
|
+
)
|
|
@@ -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 ({shell_name}) inside the agent workspace and return its output.",
|
|
46
|
+
inputSchema={
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"command": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Shell command to execute (e.g. 'cat README.md').",
|
|
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
|
+
)
|