eljay-ai 1.1.0

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 (64) hide show
  1. package/AGENTS.md +57 -0
  2. package/README.md +479 -0
  3. package/agent/__init__.py +10 -0
  4. package/agent/agent.py +269 -0
  5. package/agent/agents/__init__.py +34 -0
  6. package/agent/agents/registry.py +212 -0
  7. package/agent/builtin_tools.py +591 -0
  8. package/agent/chat.py +354 -0
  9. package/agent/config.py +67 -0
  10. package/agent/context.py +63 -0
  11. package/agent/edits.py +167 -0
  12. package/agent/gitaware.py +126 -0
  13. package/agent/hardware.py +331 -0
  14. package/agent/knowledge.py +133 -0
  15. package/agent/memory.py +98 -0
  16. package/agent/ollama_client.py +133 -0
  17. package/agent/permissions.py +93 -0
  18. package/agent/providers/__init__.py +51 -0
  19. package/agent/providers/base.py +78 -0
  20. package/agent/providers/image.py +110 -0
  21. package/agent/providers/ollama.py +111 -0
  22. package/agent/providers/video.py +96 -0
  23. package/agent/providers/web.py +155 -0
  24. package/agent/router.py +200 -0
  25. package/agent/rules.py +52 -0
  26. package/agent/runner.py +168 -0
  27. package/agent/skills.py +161 -0
  28. package/agent/tools.py +102 -0
  29. package/agent/verification.py +80 -0
  30. package/agent/workspace.py +487 -0
  31. package/bin/eljay +5 -0
  32. package/bin/eljay.cmd +4 -0
  33. package/bin/myagent +4 -0
  34. package/bin/myagent.cmd +4 -0
  35. package/eljay-ai-1.1.0.tgz +0 -0
  36. package/eljay.js +56 -0
  37. package/eljay.py +150 -0
  38. package/install.ps1 +28 -0
  39. package/knowledge/reference/diffusers.md +32 -0
  40. package/knowledge/reference/video-providers.md +21 -0
  41. package/knowledge/setup/comfyui.md +35 -0
  42. package/knowledge/setup/image-providers.md +16 -0
  43. package/knowledge/test-category/test-entry.md +5 -0
  44. package/myagent.py +30 -0
  45. package/package.json +40 -0
  46. package/skills/coding/code-review.md +3 -0
  47. package/skills/coding/fix-attempt-protocol.md +9 -0
  48. package/skills/debugging/root-cause-analysis.md +9 -0
  49. package/skills/general/communication.md +7 -0
  50. package/skills/laravel/authentication.md +15 -0
  51. package/skills/mysql/performance.md +9 -0
  52. package/skills/php/standards.md +8 -0
  53. package/skills/react/component-best-practices.md +8 -0
  54. package/skills/research/source-tracking.md +7 -0
  55. package/skills/security/input-validation.md +9 -0
  56. package/skills/testing/pytest-best-practices.md +7 -0
  57. package/tests/run_all.py +34 -0
  58. package/tests/test_agent_core.py +385 -0
  59. package/tests/test_capabilities.py +169 -0
  60. package/tests/test_edits.py +117 -0
  61. package/tests/test_eljay.py +209 -0
  62. package/tests/test_runner.py +114 -0
  63. package/tests/test_universal.py +458 -0
  64. package/tests/test_workspace.py +221 -0
@@ -0,0 +1,155 @@
1
+ """Web search provider — free, no-API-key research via DuckDuckGo Lite.
2
+
3
+ Uses ``urllib.request`` (stdlib) and a tiny ``html.parser`` to extract
4
+ search-result titles, URLs, and snippets from the DDG Lite endpoint.
5
+ No API key required, respects robots.txt, minimal requests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html as html_lib
11
+ import re
12
+ import urllib.parse
13
+ import urllib.request
14
+ from html.parser import HTMLParser
15
+
16
+ from .base import Provider
17
+
18
+ DDG_LITE = "https://lite.duckduckgo.com/lite/"
19
+ USER_AGENT = "ElJay-AI/1.0 (local research agent; stdlib only)"
20
+ MAX_RESULTS = 10
21
+
22
+
23
+ class _DDGResultParser(HTMLParser):
24
+ """Extract result anchors + snippets from the DDG Lite HTML table."""
25
+
26
+ def __init__(self):
27
+ super().__init__()
28
+ self.results: list[tuple[str, str, str]] = [] # (title, url, snippet)
29
+ self._in_link = False
30
+ self._in_snippet = False
31
+ self._current_title = ""
32
+ self._current_href = ""
33
+ self._snippet_parts: list[str] = []
34
+
35
+ def handle_starttag(self, tag, attrs):
36
+ attrs_d = dict(attrs)
37
+ if tag == "a" and attrs_d.get("class") == "result-link":
38
+ self._in_link = True
39
+ self._current_href = attrs_d.get("href", "")
40
+ elif tag == "td" and attrs_d.get("class") == "result-snippet":
41
+ self._in_snippet = True
42
+
43
+ def handle_endtag(self, tag):
44
+ if tag == "a" and self._in_link:
45
+ self._in_link = False
46
+ if self._current_title and self._extract_url(self._current_href):
47
+ self.results.append(
48
+ (
49
+ html_lib.unescape(self._current_title).strip(),
50
+ self._extract_url(self._current_href),
51
+ "",
52
+ )
53
+ )
54
+ elif tag == "td" and self._in_snippet:
55
+ self._in_snippet = False
56
+ snippet = " ".join(self._snippet_parts).strip()
57
+ if self.results and not self.results[-1][2]:
58
+ title, url, _ = self.results[-1]
59
+ self.results[-1] = (title, url, snippet)
60
+ self._current_title = ""
61
+ self._snippet_parts = []
62
+
63
+ def handle_data(self, data):
64
+ if self._in_link:
65
+ self._current_title += data
66
+ elif self._in_snippet:
67
+ self._snippet_parts.append(data)
68
+
69
+ @staticmethod
70
+ def _extract_url(href: str) -> str:
71
+ """Extract the real destination from a DDG redirect URL."""
72
+ parsed = urllib.parse.urlparse(href)
73
+ params = urllib.parse.parse_qs(parsed.query)
74
+ uddg = params.get("uddg", [""])[0]
75
+ if uddg:
76
+ return html_lib.unescape(uddg)
77
+ return href
78
+
79
+
80
+ def search_ddg(query: str, max_results: int = MAX_RESULTS) -> list[dict]:
81
+ """Search DuckDuckGo Lite and return structured results."""
82
+ url = DDG_LITE + "?" + urllib.parse.urlencode({"q": query, "kl": "us-en"})
83
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
84
+ try:
85
+ with urllib.request.urlopen(req, timeout=15) as resp:
86
+ html_text = resp.read().decode("utf-8", errors="replace")
87
+ except Exception as exc:
88
+ return [{"error": f"Search request failed: {exc}"}]
89
+
90
+ parser = _DDGResultParser()
91
+ parser.feed(html_text)
92
+
93
+ out = []
94
+ for title, url, snippet in parser.results[:max_results]:
95
+ out.append(
96
+ {"title": title or "(no title)", "url": url, "snippet": snippet}
97
+ )
98
+ if not out:
99
+ out.append({"error": "No results returned for this query."})
100
+ return out
101
+
102
+
103
+ def fetch_web_page(url: str, max_chars: int = 8000) -> str:
104
+ """Fetch *url* and return readable text (tags stripped).
105
+
106
+ Honest error messages on failure; never raises to the caller.
107
+ """
108
+ if not url.startswith(("http://", "https://")):
109
+ return f"ERROR: not a valid URL: {url}"
110
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
111
+ try:
112
+ with urllib.request.urlopen(req, timeout=20) as resp:
113
+ raw = resp.read()
114
+ except Exception as exc:
115
+ return f"ERROR: could not fetch {url}: {exc}"
116
+
117
+ try:
118
+ text = raw.decode("utf-8", errors="replace")
119
+ except Exception:
120
+ text = raw.decode("latin-1", errors="replace")
121
+
122
+ # Strip scripts and styles.
123
+ text = re.sub( r"<(script|style)[^>]*>.*?</\1>", "", text, flags=re.DOTALL | re.IGNORECASE)
124
+ # Strip remaining HTML tags.
125
+ text = re.sub(r"<[^>]+>", " ", text)
126
+ # Collapse whitespace.
127
+ text = re.sub(r"\s+", " ", text).strip()
128
+ if len(text) > max_chars:
129
+ text = text[:max_chars] + " ... [truncated]"
130
+ return text
131
+
132
+
133
+ class WebSearchProvider(Provider):
134
+ """Free, no-API-key web search via DuckDuckGo Lite."""
135
+
136
+ def __init__(self):
137
+ super().__init__(
138
+ name="duckduckgo",
139
+ label="DuckDuckGo (free web)",
140
+ capabilities=["web-search", "web-fetch"],
141
+ )
142
+ self.query: str | None = None
143
+
144
+ def detect(self) -> bool:
145
+ """DDG Lite is always available in principle; we test reachability."""
146
+ try:
147
+ results = search_ddg("test connectivity", max_results=1)
148
+ if results and "error" not in results[0]:
149
+ self.available = True
150
+ return True
151
+ self.error = "Could not reach DuckDuckGo Lite."
152
+ return False
153
+ except Exception as exc:
154
+ self.error = str(exc)
155
+ return False
@@ -0,0 +1,200 @@
1
+ """Agent routing and orchestration for ElJay AI.
2
+
3
+ The ``AgentRouter`` bridges the user's natural-language request and the
4
+ specialized agent definitions in ``agent/agents/registry.py``. It decides:
5
+
6
+ * Which agent persona to use (coding, research, image, video, knowledge, or
7
+ the main coordinator).
8
+ * Which system-prompt additions to inject.
9
+ * Which tools to make available.
10
+ * Which skills and knowledge entries are relevant.
11
+
12
+ This is a lightweight, deterministic layer — routing uses keyword matching
13
+ (no LLM call) so it is fast and predictable.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import Optional
20
+
21
+ from .agents import AgentDefinition, get_agent, list_agents, route_request
22
+ from .providers import PROVIDERS, get_provider_capable_of
23
+ from .skills import Skills
24
+ from .knowledge import KnowledgeBase
25
+
26
+
27
+ # Capability → provider-name map (matches Provider names in PROVIDERS).
28
+ _PROVIDER_NAMES = {
29
+ "text": "ollama",
30
+ "coding": "ollama",
31
+ "web-search": "duckduckgo",
32
+ "web-fetch": "duckduckgo",
33
+ "image-generation": "image-local",
34
+ "video-generation": "video-local",
35
+ }
36
+
37
+
38
+ class AgentRouter:
39
+ """Route user queries to the right agent persona.
40
+
41
+ Parameters
42
+ ----------
43
+ core
44
+ The :class:`AgentCore` instance (optional, for future multi-agent
45
+ delegation).
46
+ workspace
47
+ The :class:`Workspace` (optional, used for project-local context).
48
+ skills
49
+ The :class:`Skills` instance (may be empty if no ``skills/`` dir).
50
+ knowledge
51
+ The :class:`KnowledgeBase` instance (may be empty).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ core: object | None = None,
57
+ skills: Skills | None = None,
58
+ knowledge: KnowledgeBase | None = None,
59
+ workspace: object | None = None,
60
+ ) -> None:
61
+ self.core = core
62
+ self.workspace = workspace
63
+ self.skills = skills or _EmptySkills()
64
+ self.knowledge = knowledge or _EmptyKnowledge()
65
+ self.registry = list_agents()
66
+ self._providers_checked = False
67
+ self._provider_status: dict[str, bool] = {}
68
+
69
+ # ------------------------------------------------------------------
70
+ # Provider status
71
+ # ------------------------------------------------------------------
72
+
73
+ def provider_status(self) -> dict[str, bool]:
74
+ """Return ``{provider_name: is_available}`` for all providers."""
75
+ if not self._providers_checked:
76
+ self._providers_checked = True
77
+ for p in PROVIDERS:
78
+ key = p.name
79
+ try:
80
+ self._provider_status[key] = p.is_available()
81
+ except Exception:
82
+ self._provider_status[key] = False
83
+ return self._provider_status
84
+
85
+ # ------------------------------------------------------------------
86
+ # Routing
87
+ # ------------------------------------------------------------------
88
+
89
+ def route(self, query: str) -> AgentDefinition:
90
+ """Return the agent definition best suited to *query*."""
91
+ providers = self.provider_status()
92
+ # Translate capability names to boolean availability.
93
+ available = {}
94
+ for p in PROVIDERS:
95
+ available[p.name] = providers.get(p.name, False)
96
+ return route_request(query, available)
97
+
98
+ def __len__(self) -> int:
99
+ """Return the number of registered agents."""
100
+ return len(self.registry)
101
+
102
+ def agent_by_name(self, name: str) -> Optional[AgentDefinition]:
103
+ """Look up an agent by name (case-insensitive)."""
104
+ return get_agent(name.lower())
105
+
106
+ def all_agents(self) -> list[AgentDefinition]:
107
+ """Return all agent definitions, most specific first."""
108
+ return list_agents()
109
+
110
+ # ------------------------------------------------------------------
111
+ # System prompt augmentation
112
+ # ------------------------------------------------------------------
113
+
114
+ def system_additions(self, agent: AgentDefinition) -> str:
115
+ """Return the system-prompt additions for *agent*."""
116
+ return agent.system_additions
117
+
118
+ def available_tools(self, agent: AgentDefinition) -> list[str]:
119
+ """Return the list of tool names the agent may use.
120
+
121
+ The ``main`` coordinator gets all tools; specialised agents get only
122
+ the tools listed in their definition.
123
+ """
124
+ if agent.name == "main":
125
+ return [] # empty list = "all tools" (convention)
126
+ return list(agent.tools)
127
+
128
+ # ------------------------------------------------------------------
129
+ # Skills & Knowledge injection
130
+ # ------------------------------------------------------------------
131
+
132
+ def relevant_skills(self, query: str, limit: int = 3) -> list[str]:
133
+ """Return relevant skill content snippets for *query*."""
134
+ skills = self.skills.relevant_to(query)
135
+ return [s.content for s in skills[:limit]]
136
+
137
+ def relevant_knowledge(self, query: str, limit: int = 3) -> list[str]:
138
+ """Return relevant knowledge entries for *query*."""
139
+ entries = self.knowledge.search(query)
140
+ return [e.content for e in entries[:limit]]
141
+
142
+ def inject_context(self, query: str, agent: AgentDefinition) -> str:
143
+ """Build a context string to append to the system prompt.
144
+
145
+ Includes relevant skills and knowledge entries for the agent + query.
146
+ """
147
+ parts: list[str] = []
148
+ skills = self.relevant_skills(query)
149
+ if skills:
150
+ parts.append("## Relevant Skills\n\n" + "\n\n---\n\n".join(skills))
151
+ knowledge = self.relevant_knowledge(query)
152
+ if knowledge:
153
+ parts.append("## Relevant Knowledge\n\n" + "\n\n---\n\n".join(knowledge))
154
+ return "\n\n".join(parts)
155
+
156
+ # ------------------------------------------------------------------
157
+ # Orchestration (future: multi-agent delegation)
158
+ # ------------------------------------------------------------------
159
+
160
+ def needs_delegation(self, query: str) -> bool:
161
+ """Return True if this query is better handled by a specialist agent.
162
+
163
+ The Main coordinator delegates when the matched agent is not ``main``.
164
+ """
165
+ agent = self.route(query)
166
+ return agent.name != "main"
167
+
168
+ def delegated_agent_for(self, query: str) -> Optional[AgentDefinition]:
169
+ """Return the specialist agent for *query*, or None if Main is best."""
170
+ agent = self.route(query)
171
+ if agent.name == "main":
172
+ return None
173
+ return agent
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Lightweight empty fallbacks so the router works even when no skills/
178
+ # knowledge directories exist yet.
179
+ # ---------------------------------------------------------------------------
180
+
181
+ class _EmptySkills:
182
+ def relevant_to(self, query: str) -> list:
183
+ return []
184
+
185
+ def all_skills(self) -> list:
186
+ return []
187
+
188
+ def summary(self) -> str:
189
+ return "no skills"
190
+
191
+
192
+ class _EmptyKnowledge:
193
+ def search(self, query: str) -> list:
194
+ return []
195
+
196
+ def entries(self) -> list:
197
+ return []
198
+
199
+ def summary(self) -> str:
200
+ return "empty"
package/agent/rules.py ADDED
@@ -0,0 +1,52 @@
1
+ """Project rules: load AGENTS.md and turn it into system instructions.
2
+
3
+ The agent must read the project's rules before making changes, so the rules are
4
+ appended to the system prompt at startup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .workspace import Workspace
10
+
11
+ RULES_FILENAMES = ("AGENTS.md", "agents.md", ".agent/AGENTS.md")
12
+ MAX_RULES_CHARS = 8000
13
+
14
+
15
+ def find_rules_file(ws: Workspace) -> str | None:
16
+ for name in RULES_FILENAMES:
17
+ try:
18
+ path = ws.resolve(name)
19
+ except ValueError:
20
+ continue
21
+ if path.is_file():
22
+ return name
23
+ return None
24
+
25
+
26
+ def load_rules(ws: Workspace) -> tuple[str, str | None]:
27
+ """Return (rules_text, filename). rules_text is "" when there are none."""
28
+ name = find_rules_file(ws)
29
+ if name is None:
30
+ return "", None
31
+ try:
32
+ path = ws.resolve(name)
33
+ text = path.read_text(encoding="utf-8", errors="replace")
34
+ except (OSError, ValueError):
35
+ return "", None
36
+
37
+ if len(text) > MAX_RULES_CHARS:
38
+ text = text[:MAX_RULES_CHARS] + "\n... (rules truncated)"
39
+ return text.strip(), name
40
+
41
+
42
+ def build_system_prompt(base_prompt: str, rules_text: str, source: str | None) -> str:
43
+ if not rules_text:
44
+ return base_prompt
45
+ return (
46
+ f"{base_prompt}\n\n"
47
+ f"PROJECT RULES (from {source}) — these are binding. Follow them, and ask "
48
+ "before doing anything they forbid:\n"
49
+ "-----\n"
50
+ f"{rules_text}\n"
51
+ "-----"
52
+ )
@@ -0,0 +1,168 @@
1
+ """Terminal command execution — deliberately narrow.
2
+
3
+ Safety model:
4
+ - Commands are NEVER auto-run (never `safe`). Every command needs approval.
5
+ - A command whose program is on the allow-list is `ask`; anything else is `high`.
6
+ - Known-dangerous patterns force `high` regardless of the program.
7
+ - Shell operators (|, &, ;, <, >, backticks, $(...)) are refused: we run a single
8
+ program without a shell, so there is no shell injection surface.
9
+ - The working directory is confined to the project root.
10
+ - Output is captured, and the run is bounded by a timeout.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import shlex
18
+ import shutil
19
+ import subprocess
20
+
21
+ from .tools import RISK_ASK, RISK_HIGH
22
+ from .workspace import Workspace
23
+
24
+ MAX_OUTPUT_CHARS = 20_000
25
+ DEFAULT_TIMEOUT = 120
26
+ MAX_TIMEOUT = 600
27
+
28
+ # Programs that may run with a normal confirmation.
29
+ ALLOWED_PROGRAMS = frozenset(
30
+ {
31
+ # python
32
+ "python", "python3", "py", "pip", "pip3", "pytest", "mypy", "ruff", "black",
33
+ # javascript / typescript
34
+ "node", "npm", "npx", "yarn", "pnpm", "bun", "tsc", "eslint", "vite", "jest",
35
+ # php
36
+ "php", "composer", "artisan",
37
+ # git (dangerous subcommands are caught below)
38
+ "git",
39
+ # other languages / build tools
40
+ "go", "cargo", "rustc", "dotnet", "java", "mvn", "gradle", "make",
41
+ "ruby", "bundle", "gem",
42
+ # harmless inspection
43
+ "echo", "ls", "dir", "cat", "type", "pwd", "where", "which", "whoami",
44
+ "node", "tree",
45
+ }
46
+ )
47
+
48
+ # Patterns that ALWAYS mean high risk, whatever the program.
49
+ _HIGH_RISK_PATTERNS = tuple(
50
+ re.compile(p)
51
+ for p in (
52
+ r"\brm\b", r"\brmdir\b", r"\brd\b", r"\bdel\b", r"\berase\b",
53
+ r"\bformat\b", r"\bfdisk\b", r"\bmkfs\b",
54
+ r"\bshutdown\b", r"\breboot\b", r"\btaskkill\b", r"\bkill\b", r"\bsudo\b",
55
+ r"git\s+push", r"git\s+reset", r"git\s+clean", r"git\s+rebase",
56
+ r"git\s+checkout\s+--", r"git\s+branch\s+-d", r"git\s+filter-branch",
57
+ r"npm\s+uninstall", r"npm\s+install\s+(-g|--global)",
58
+ r"yarn\s+remove", r"pnpm\s+remove", r"composer\s+remove",
59
+ r"pip\s+uninstall", r"pip\s+install\s+--user",
60
+ r"drop\s+(database|table)", r"truncate\s+table", r"delete\s+from",
61
+ r"\|\s*(ba|z|)sh\b", r"\bcurl\b[^|]*\|", r"\bwget\b[^|]*\|",
62
+ r">\s*/dev/sd", r"\bchmod\s+777\b",
63
+ )
64
+ )
65
+
66
+ # Shell operators we do not support (we never use a shell).
67
+ _SHELL_META = re.compile(r"[|&;<>]|\$\(|`")
68
+
69
+
70
+ def analyse(command: str) -> tuple[list[str] | None, str]:
71
+ """Parse a command into argv. Returns (argv, "") or (None, error_message)."""
72
+ text = (command or "").strip()
73
+ if not text:
74
+ return None, "ERROR: command is required"
75
+ if _SHELL_META.search(text):
76
+ return None, (
77
+ "ERROR: shell operators (|, &, ;, <, >, backticks, $(...)) are not "
78
+ "allowed. Run a single command with no pipes or redirection."
79
+ )
80
+ try:
81
+ parts = shlex.split(text, posix=True)
82
+ except ValueError as exc:
83
+ return None, f"ERROR: could not parse the command: {exc}"
84
+ if not parts:
85
+ return None, "ERROR: command is required"
86
+ return parts, ""
87
+
88
+
89
+ def _program_name(token: str) -> str:
90
+ name = os.path.basename(token).lower()
91
+ for ext in (".exe", ".cmd", ".bat", ".com"):
92
+ if name.endswith(ext):
93
+ name = name[: -len(ext)]
94
+ return name
95
+
96
+
97
+ def classify_command(command: str) -> str:
98
+ """Return RISK_ASK for allow-listed programs, RISK_HIGH otherwise."""
99
+ lowered = (command or "").lower()
100
+ for pattern in _HIGH_RISK_PATTERNS:
101
+ if pattern.search(lowered):
102
+ return RISK_HIGH
103
+ argv, _err = analyse(command)
104
+ if not argv:
105
+ return RISK_HIGH
106
+ return RISK_ASK if _program_name(argv[0]) in ALLOWED_PROGRAMS else RISK_HIGH
107
+
108
+
109
+ def run_command(
110
+ ws: Workspace,
111
+ command: str,
112
+ cwd: str = ".",
113
+ timeout: int = DEFAULT_TIMEOUT,
114
+ ) -> str:
115
+ """Run one command in the project root and return a readable report."""
116
+ argv, error = analyse(command)
117
+ if argv is None:
118
+ return error
119
+
120
+ try:
121
+ workdir = ws.resolve(cwd or ".")
122
+ except ValueError as exc:
123
+ return f"ERROR: {exc}"
124
+ if not workdir.is_dir():
125
+ return f"ERROR: working directory does not exist: {cwd}"
126
+
127
+ program = shutil.which(argv[0])
128
+ if program is None:
129
+ return f"ERROR: command not found: {argv[0]}"
130
+ argv[0] = program
131
+
132
+ try:
133
+ timeout = max(1, min(int(timeout), MAX_TIMEOUT))
134
+ except (TypeError, ValueError):
135
+ timeout = DEFAULT_TIMEOUT
136
+
137
+ try:
138
+ proc = subprocess.run(
139
+ argv,
140
+ cwd=str(workdir),
141
+ capture_output=True,
142
+ text=True,
143
+ encoding="utf-8",
144
+ errors="replace",
145
+ timeout=timeout,
146
+ )
147
+ except subprocess.TimeoutExpired:
148
+ return (
149
+ f"Command: {command}\nResult: FAILED\n"
150
+ f"Reason: timed out after {timeout}s and was stopped."
151
+ )
152
+ except (OSError, ValueError) as exc:
153
+ return f"ERROR: could not run '{command}': {exc}"
154
+
155
+ output = proc.stdout or ""
156
+ if proc.stderr:
157
+ output += ("\n" if output else "") + "[stderr]\n" + proc.stderr
158
+ output = output.strip()
159
+ if len(output) > MAX_OUTPUT_CHARS:
160
+ output = output[:MAX_OUTPUT_CHARS] + "\n... (output truncated)"
161
+
162
+ status = "succeeded" if proc.returncode == 0 else "FAILED"
163
+ report = f"Command: {command}\nExit code: {proc.returncode} ({status})"
164
+ if output:
165
+ report += f"\n--- output ---\n{output}"
166
+ else:
167
+ report += "\n(no output)"
168
+ return report