aleph-rlm 0.6.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.
@@ -0,0 +1,166 @@
1
+ """Sub-query module for RLM-style recursive reasoning.
2
+
3
+ This module enables Aleph to spawn sub-agents that can reason over context slices,
4
+ following the Recursive Language Model (RLM) paradigm.
5
+
6
+ Backend priority (configurable via ALEPH_SUB_QUERY_BACKEND):
7
+ 1. API (if credentials available) - OpenAI-compatible APIs only
8
+ 2. CLI backends (codex, gemini) - uses existing subscriptions
9
+ Note: claude CLI is deprioritized as it hangs in MCP/sandbox contexts
10
+
11
+ Configuration via environment:
12
+ - ALEPH_SUB_QUERY_API_KEY (or OPENAI_API_KEY fallback)
13
+ - ALEPH_SUB_QUERY_URL (or OPENAI_BASE_URL fallback, default: https://api.openai.com/v1)
14
+ - ALEPH_SUB_QUERY_MODEL (required)
15
+ - ALEPH_SUB_QUERY_SHARE_SESSION (share live MCP session with CLI sub-agents)
16
+ - ALEPH_SUB_QUERY_HTTP_HOST / ALEPH_SUB_QUERY_HTTP_PORT / ALEPH_SUB_QUERY_HTTP_PATH
17
+ - ALEPH_SUB_QUERY_MCP_SERVER_NAME (server name exposed to sub-agents)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ from dataclasses import dataclass, field
25
+ from typing import Literal
26
+
27
+ __all__ = [
28
+ "SubQueryConfig",
29
+ "detect_backend",
30
+ "DEFAULT_CONFIG",
31
+ "has_api_credentials",
32
+ ]
33
+
34
+
35
+ BackendType = Literal["claude", "codex", "gemini", "api", "auto"]
36
+
37
+ DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
38
+ DEFAULT_API_KEY_ENV = "ALEPH_SUB_QUERY_API_KEY"
39
+ DEFAULT_API_BASE_URL_ENV = "ALEPH_SUB_QUERY_URL"
40
+ DEFAULT_API_MODEL_ENV = "ALEPH_SUB_QUERY_MODEL"
41
+
42
+
43
+ @dataclass
44
+ class SubQueryConfig:
45
+ """Configuration for sub-query backend.
46
+
47
+ The backend priority can be configured via environment variables:
48
+
49
+ - ALEPH_SUB_QUERY_BACKEND: Force a specific backend ("api", "claude", "codex", "gemini")
50
+ - ALEPH_SUB_QUERY_API_KEY: API key for OpenAI-compatible providers (fallback: OPENAI_API_KEY)
51
+ - ALEPH_SUB_QUERY_URL: Base URL for OpenAI-compatible APIs (fallback: OPENAI_BASE_URL)
52
+ - ALEPH_SUB_QUERY_MODEL: Model name (required)
53
+ - ALEPH_SUB_QUERY_SHARE_SESSION: Share live MCP session with CLI sub-agents
54
+ - ALEPH_SUB_QUERY_HTTP_HOST / ALEPH_SUB_QUERY_HTTP_PORT / ALEPH_SUB_QUERY_HTTP_PATH
55
+ - ALEPH_SUB_QUERY_MCP_SERVER_NAME: Server name exposed to sub-agents
56
+
57
+ When backend="auto" (default), the priority is:
58
+ 1. API - if API credentials are available
59
+ 2. codex CLI - if installed
60
+ 3. gemini CLI - if installed
61
+ 4. claude CLI - if installed (deprioritized: hangs in MCP/sandbox contexts)
62
+
63
+ Attributes:
64
+ backend: Which backend to use. "auto" prioritizes API, then CLI.
65
+ cli_timeout_seconds: Timeout for CLI subprocess calls.
66
+ cli_max_output_chars: Maximum output characters from CLI.
67
+ api_timeout_seconds: Timeout for API calls.
68
+ api_key_env: Environment variable name for API key.
69
+ api_base_url_env: Environment variable name for API base URL.
70
+ api_model_env: Environment variable name for API model.
71
+ api_model: Explicit model override (if provided programmatically).
72
+ max_context_chars: Truncate context slices longer than this.
73
+ include_system_prompt: Whether to include a system prompt for sub-queries.
74
+ """
75
+
76
+ backend: BackendType = "auto"
77
+
78
+ # CLI options
79
+ cli_timeout_seconds: float = 120.0
80
+ cli_max_output_chars: int = 50_000
81
+
82
+ # API options
83
+ api_timeout_seconds: float = 60.0
84
+ api_key_env: str = DEFAULT_API_KEY_ENV
85
+ api_base_url_env: str = DEFAULT_API_BASE_URL_ENV
86
+ api_model_env: str = DEFAULT_API_MODEL_ENV
87
+ api_model: str | None = None
88
+
89
+ # Behavior
90
+ max_context_chars: int = 100_000
91
+ include_system_prompt: bool = True
92
+
93
+ # System prompt for sub-queries
94
+ system_prompt: str = field(
95
+ default="""You are a focused sub-agent processing a single task. This is a one-shot operation.
96
+
97
+ INSTRUCTIONS:
98
+ 1. Answer the question based ONLY on the provided context
99
+ 2. Be concise - provide direct answers without preamble
100
+ 3. If context is insufficient, say "INSUFFICIENT_CONTEXT: [what's missing]"
101
+ 4. Structure your response for easy parsing:
102
+ - For summaries: bullet points or numbered lists
103
+ - For extractions: key: value format
104
+ - For analysis: clear sections with headers
105
+ 5. Do not make up information not present in the context
106
+
107
+ OUTPUT FORMAT:
108
+ - Start directly with your answer (no "Based on the context..." preamble)
109
+ - End with a confidence indicator if uncertain: [CONFIDENCE: high/medium/low]"""
110
+ )
111
+
112
+
113
+ def _get_api_key(api_key_env: str) -> str | None:
114
+ """Return API key from explicit env var or OPENAI_API_KEY fallback."""
115
+ return os.environ.get(api_key_env) or os.environ.get("OPENAI_API_KEY")
116
+
117
+
118
+ def has_api_credentials(config: SubQueryConfig | None = None) -> bool:
119
+ """Check if API credentials are available for the sub-query backend."""
120
+ cfg = config or DEFAULT_CONFIG
121
+ return _get_api_key(cfg.api_key_env) is not None
122
+
123
+
124
+ def detect_backend(config: SubQueryConfig | None = None) -> BackendType:
125
+ """Auto-detect the best available backend.
126
+
127
+ Priority (API-first for reliability and configurability):
128
+ 1. Check ALEPH_SUB_QUERY_BACKEND env var for explicit override
129
+ 2. api - if API credentials are available
130
+ 3. codex CLI - if installed
131
+ 4. gemini CLI - if installed
132
+ 5. claude CLI - if installed (deprioritized: hangs in MCP/sandbox contexts)
133
+ 6. api (fallback) - will error if no credentials, but gives helpful message
134
+
135
+ Returns:
136
+ The detected backend type.
137
+ """
138
+ cfg = config or DEFAULT_CONFIG
139
+
140
+ # Check for explicit backend override
141
+ explicit_backend = os.environ.get("ALEPH_SUB_QUERY_BACKEND", "").lower().strip()
142
+ if explicit_backend in ("api", "claude", "codex", "gemini"):
143
+ return explicit_backend # type: ignore
144
+
145
+ # Prefer API if explicit model is set and credentials exist
146
+ if (cfg.api_model or os.environ.get(cfg.api_model_env)) and has_api_credentials(cfg):
147
+ return "api"
148
+
149
+ # Priority 1: API if credentials are available
150
+ if has_api_credentials(cfg):
151
+ return "api"
152
+
153
+ # Priority 2-4: CLI backends (codex/gemini preferred over claude)
154
+ # Note: claude CLI hangs in MCP/sandbox contexts, so it's deprioritized
155
+ if shutil.which("codex"):
156
+ return "codex"
157
+ if shutil.which("gemini"):
158
+ return "gemini"
159
+ if shutil.which("claude"):
160
+ return "claude"
161
+
162
+ # Fallback to API (will error with helpful message if no credentials)
163
+ return "api"
164
+
165
+
166
+ DEFAULT_CONFIG = SubQueryConfig()
@@ -0,0 +1,166 @@
1
+ """API backend for sub-queries.
2
+
3
+ Supports OpenAI-compatible chat completions endpoints.
4
+
5
+ Configuration via environment variables:
6
+ - ALEPH_SUB_QUERY_API_KEY: API key (fallback: OPENAI_API_KEY)
7
+ - ALEPH_SUB_QUERY_URL: Base URL (fallback: OPENAI_BASE_URL, default: https://api.openai.com/v1)
8
+ - ALEPH_SUB_QUERY_MODEL: Model name (required)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+
16
+ from . import (
17
+ DEFAULT_API_BASE_URL_ENV,
18
+ DEFAULT_API_KEY_ENV,
19
+ DEFAULT_API_MODEL_ENV,
20
+ DEFAULT_OPENAI_BASE_URL,
21
+ )
22
+
23
+ __all__ = ["run_api_sub_query"]
24
+
25
+
26
+ def _get_api_key(api_key_env: str) -> str | None:
27
+ return os.environ.get(api_key_env) or os.environ.get("OPENAI_API_KEY")
28
+
29
+
30
+ def _get_base_url(api_base_url_env: str) -> str:
31
+ return (
32
+ os.environ.get(api_base_url_env)
33
+ or os.environ.get("OPENAI_BASE_URL")
34
+ or DEFAULT_OPENAI_BASE_URL
35
+ )
36
+
37
+
38
+ def _get_model(api_model_env: str) -> str | None:
39
+ return os.environ.get(api_model_env)
40
+
41
+
42
+ async def _call_openai_compatible(
43
+ messages: list[dict[str, Any]],
44
+ model: str,
45
+ api_key: str,
46
+ base_url: str,
47
+ timeout: float,
48
+ max_tokens: int,
49
+ ) -> tuple[bool, str]:
50
+ """Call OpenAI-compatible chat completions API.
51
+
52
+ Works with: OpenAI, Groq, Together, Mistral, DeepSeek, local LLMs, etc.
53
+ """
54
+ try:
55
+ import httpx
56
+ except ImportError:
57
+ return False, "httpx not installed. Run: pip install httpx"
58
+
59
+ url = f"{base_url.rstrip('/')}/chat/completions"
60
+ headers = {
61
+ "Content-Type": "application/json",
62
+ "Authorization": f"Bearer {api_key}",
63
+ }
64
+ payload = {
65
+ "model": model,
66
+ "messages": messages,
67
+ "max_tokens": max_tokens,
68
+ }
69
+
70
+ async with httpx.AsyncClient() as client:
71
+ try:
72
+ resp = await client.post(
73
+ url,
74
+ json=payload,
75
+ headers=headers,
76
+ timeout=timeout,
77
+ )
78
+
79
+ if resp.status_code != 200:
80
+ try:
81
+ err_data = resp.json()
82
+ err_msg = err_data.get("error", {}).get("message", resp.text)
83
+ except Exception:
84
+ err_msg = resp.text[:500]
85
+ return False, f"API error {resp.status_code}: {err_msg}"
86
+
87
+ data = resp.json()
88
+ text = data["choices"][0]["message"]["content"]
89
+ return True, text
90
+
91
+ except httpx.TimeoutException:
92
+ return False, f"API timeout after {timeout}s"
93
+ except httpx.ConnectError as e:
94
+ return False, f"API connection error: {e}. Check ALEPH_SUB_QUERY_URL."
95
+ except (KeyError, IndexError) as e:
96
+ return False, f"Failed to parse API response: {e}"
97
+ except Exception as e:
98
+ return False, f"API request failed: {e}"
99
+
100
+
101
+ async def run_api_sub_query(
102
+ prompt: str,
103
+ context_slice: str | None = None,
104
+ model: str | None = None,
105
+ api_key_env: str = DEFAULT_API_KEY_ENV,
106
+ api_base_url_env: str = DEFAULT_API_BASE_URL_ENV,
107
+ api_model_env: str = DEFAULT_API_MODEL_ENV,
108
+ timeout: float = 60.0,
109
+ system_prompt: str | None = None,
110
+ max_tokens: int = 8192,
111
+ ) -> tuple[bool, str]:
112
+ """Run sub-query via OpenAI-compatible API.
113
+
114
+ Configuration via environment:
115
+ - ALEPH_SUB_QUERY_API_KEY: API key (fallback: OPENAI_API_KEY)
116
+ - ALEPH_SUB_QUERY_URL: Custom endpoint (fallback: OPENAI_BASE_URL)
117
+ - ALEPH_SUB_QUERY_MODEL: Required model name
118
+
119
+ Args:
120
+ prompt: The question/task for the sub-agent.
121
+ context_slice: Optional context to include.
122
+ model: Model name (required if ALEPH_SUB_QUERY_MODEL is not set).
123
+ api_key_env: Env var name for API key.
124
+ api_base_url_env: Env var name for API base URL.
125
+ api_model_env: Env var name for API model.
126
+ timeout: Request timeout in seconds.
127
+ system_prompt: Optional system prompt.
128
+ max_tokens: Maximum tokens in response.
129
+
130
+ Returns:
131
+ Tuple of (success, output).
132
+ """
133
+ api_key = _get_api_key(api_key_env)
134
+ if not api_key:
135
+ return False, (
136
+ "No API key found. Set ALEPH_SUB_QUERY_API_KEY (preferred) or OPENAI_API_KEY."
137
+ )
138
+
139
+ if model is None:
140
+ model = _get_model(api_model_env)
141
+ if not model:
142
+ return False, (
143
+ "No model configured. Set ALEPH_SUB_QUERY_MODEL or pass model=..."
144
+ )
145
+
146
+ base_url = _get_base_url(api_base_url_env)
147
+
148
+ # Build the full prompt
149
+ full_prompt = prompt
150
+ if context_slice:
151
+ full_prompt = f"{prompt}\n\n---\nContext:\n{context_slice}"
152
+
153
+ # Build messages
154
+ messages: list[dict[str, Any]] = []
155
+ if system_prompt:
156
+ messages.append({"role": "system", "content": system_prompt})
157
+ messages.append({"role": "user", "content": full_prompt})
158
+
159
+ return await _call_openai_compatible(
160
+ messages=messages,
161
+ model=model,
162
+ api_key=api_key,
163
+ base_url=base_url,
164
+ timeout=timeout,
165
+ max_tokens=max_tokens,
166
+ )
@@ -0,0 +1,327 @@
1
+ """CLI backend for sub-queries.
2
+
3
+ Spawns CLI tools (claude, codex) as sub-agents.
4
+ This allows RLM-style recursive reasoning without API keys.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ __all__ = ["run_cli_sub_query", "CLI_BACKENDS"]
18
+
19
+
20
+ CLI_BACKENDS = ("claude", "codex", "gemini")
21
+
22
+ _KEEP_MCP_CONFIG_ENV = "ALEPH_SUB_QUERY_KEEP_MCP_CONFIG"
23
+
24
+
25
+ def _env_bool(name: str, default: bool = False) -> bool:
26
+ value = os.environ.get(name)
27
+ if value is None:
28
+ return default
29
+ return value.strip().lower() in ("1", "true", "yes", "on")
30
+
31
+
32
+ def _track_cleanup(path: Path, cleanup_paths: list[Path]) -> None:
33
+ if _env_bool(_KEEP_MCP_CONFIG_ENV, False):
34
+ print(f"[aleph] Keeping MCP config: {path}", file=sys.stderr)
35
+ else:
36
+ cleanup_paths.append(path)
37
+
38
+
39
+ async def run_cli_sub_query(
40
+ prompt: str,
41
+ context_slice: str | None = None,
42
+ backend: Literal["claude", "codex", "gemini"] = "claude",
43
+ timeout: float = 120.0,
44
+ cwd: Path | None = None,
45
+ max_output_chars: int = 50_000,
46
+ mcp_server_url: str | None = None,
47
+ mcp_server_name: str = "aleph_shared",
48
+ trust_mcp_server: bool = True,
49
+ ) -> tuple[bool, str]:
50
+ """Spawn a CLI sub-agent and return its response.
51
+
52
+ Args:
53
+ prompt: The question/task for the sub-agent.
54
+ context_slice: Optional context to include.
55
+ backend: Which CLI tool to use.
56
+ timeout: Timeout in seconds.
57
+ cwd: Working directory for the subprocess.
58
+ max_output_chars: Maximum output characters.
59
+
60
+ Returns:
61
+ Tuple of (success, output).
62
+ """
63
+ # Build the full prompt
64
+ full_prompt = prompt
65
+ if context_slice:
66
+ full_prompt = f"{prompt}\n\n---\nContext:\n{context_slice}"
67
+
68
+ # For very long prompts, write to a temp file and pass via stdin/file
69
+ use_tempfile = len(full_prompt) > 10_000
70
+
71
+ try:
72
+ if use_tempfile:
73
+ return await _run_with_tempfile(
74
+ full_prompt,
75
+ backend,
76
+ timeout,
77
+ cwd,
78
+ max_output_chars,
79
+ mcp_server_url=mcp_server_url,
80
+ mcp_server_name=mcp_server_name,
81
+ trust_mcp_server=trust_mcp_server,
82
+ )
83
+ else:
84
+ return await _run_with_arg(
85
+ full_prompt,
86
+ backend,
87
+ timeout,
88
+ cwd,
89
+ max_output_chars,
90
+ mcp_server_url=mcp_server_url,
91
+ mcp_server_name=mcp_server_name,
92
+ trust_mcp_server=trust_mcp_server,
93
+ )
94
+ except FileNotFoundError:
95
+ return False, f"CLI backend '{backend}' not found. Install it or use API fallback."
96
+ except Exception as e:
97
+ return False, f"CLI error: {e}"
98
+
99
+
100
+ def _codex_mcp_overrides(
101
+ mcp_server_url: str,
102
+ mcp_server_name: str,
103
+ trust_mcp_server: bool,
104
+ ) -> list[str]:
105
+ overrides = [
106
+ "-c",
107
+ f"mcp_servers.{mcp_server_name}.transport={json.dumps('streamable_http')}",
108
+ "-c",
109
+ f"mcp_servers.{mcp_server_name}.url={json.dumps(mcp_server_url)}",
110
+ ]
111
+ if trust_mcp_server:
112
+ overrides.extend(
113
+ [
114
+ "-c",
115
+ f"mcp_servers.{mcp_server_name}.trust=true",
116
+ ]
117
+ )
118
+ return overrides
119
+
120
+
121
+ def _gemini_env_for_mcp(
122
+ mcp_server_url: str,
123
+ mcp_server_name: str,
124
+ trust_mcp_server: bool,
125
+ ) -> tuple[dict[str, str], Path]:
126
+ env = os.environ.copy()
127
+ payload = {
128
+ "mcpServers": {
129
+ mcp_server_name: {
130
+ "type": "http",
131
+ "url": mcp_server_url,
132
+ "trust": trust_mcp_server,
133
+ }
134
+ }
135
+ }
136
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
137
+ json.dump(payload, f, ensure_ascii=True, indent=2)
138
+ settings_path = Path(f.name)
139
+ env["GEMINI_CLI_SYSTEM_SETTINGS_PATH"] = str(settings_path)
140
+ return env, settings_path
141
+
142
+
143
+ def _claude_mcp_config(
144
+ mcp_server_url: str,
145
+ mcp_server_name: str,
146
+ ) -> Path:
147
+ """Create a temp JSON file with MCP config for Claude CLI.
148
+
149
+ Claude CLI uses --mcp-config flag to load MCP servers from JSON files.
150
+ The format is: {"mcpServers": {"name": {"type": "http", "url": "..."}}}
151
+ """
152
+ payload = {
153
+ "mcpServers": {
154
+ mcp_server_name: {
155
+ "type": "http",
156
+ "url": mcp_server_url,
157
+ }
158
+ }
159
+ }
160
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
161
+ json.dump(payload, f, ensure_ascii=True, indent=2)
162
+ return Path(f.name)
163
+
164
+
165
+ async def _run_with_arg(
166
+ prompt: str,
167
+ backend: str,
168
+ timeout: float,
169
+ cwd: Path | None,
170
+ max_output_chars: int,
171
+ mcp_server_url: str | None,
172
+ mcp_server_name: str,
173
+ trust_mcp_server: bool,
174
+ ) -> tuple[bool, str]:
175
+ """Run CLI with prompt as argument."""
176
+ env: dict[str, str] | None = None
177
+ cleanup_paths: list[Path] = []
178
+
179
+ if backend == "claude":
180
+ # Claude Code CLI: -p for print mode (non-interactive), --dangerously-skip-permissions to bypass
181
+ mcp_args: list[str] = []
182
+ if mcp_server_url:
183
+ config_path = _claude_mcp_config(mcp_server_url, mcp_server_name)
184
+ _track_cleanup(config_path, cleanup_paths)
185
+ mcp_args = ["--mcp-config", str(config_path), "--strict-mcp-config"]
186
+ cmd = ["claude", "-p", *mcp_args, prompt, "--dangerously-skip-permissions"]
187
+ elif backend == "codex":
188
+ # OpenAI Codex CLI (non-interactive)
189
+ overrides: list[str] = []
190
+ if mcp_server_url:
191
+ overrides = _codex_mcp_overrides(mcp_server_url, mcp_server_name, trust_mcp_server)
192
+ cmd = ["codex", *overrides, "exec", "--full-auto", prompt]
193
+ elif backend == "gemini":
194
+ # Google Gemini CLI: -y for yolo mode (auto-approve all actions)
195
+ if mcp_server_url:
196
+ env, settings_path = _gemini_env_for_mcp(
197
+ mcp_server_url, mcp_server_name, trust_mcp_server
198
+ )
199
+ _track_cleanup(settings_path, cleanup_paths)
200
+ cmd = ["gemini", "-y", prompt]
201
+ else:
202
+ return False, f"Unknown CLI backend: {backend}"
203
+
204
+ try:
205
+ proc = await asyncio.create_subprocess_exec(
206
+ *cmd,
207
+ stdin=asyncio.subprocess.DEVNULL, # Prevent subprocess from reading MCP stdio.
208
+ stdout=asyncio.subprocess.PIPE,
209
+ stderr=asyncio.subprocess.PIPE,
210
+ cwd=str(cwd) if cwd else None,
211
+ env=env,
212
+ )
213
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
214
+ output = stdout.decode("utf-8", errors="replace")
215
+
216
+ if len(output) > max_output_chars:
217
+ output = output[:max_output_chars] + "\n...[truncated]"
218
+
219
+ if proc.returncode != 0:
220
+ err = stderr.decode("utf-8", errors="replace")
221
+ # Some CLIs write to stderr even on success, check if we got output
222
+ if output.strip():
223
+ return True, output
224
+ return False, f"CLI error (exit {proc.returncode}): {err[:1000]}"
225
+
226
+ return True, output
227
+ except asyncio.TimeoutError:
228
+ proc.kill()
229
+ await proc.wait()
230
+ return False, f"CLI timeout after {timeout}s"
231
+ finally:
232
+ for path in cleanup_paths:
233
+ try:
234
+ path.unlink()
235
+ except Exception:
236
+ pass
237
+
238
+
239
+ async def _run_with_tempfile(
240
+ prompt: str,
241
+ backend: str,
242
+ timeout: float,
243
+ cwd: Path | None,
244
+ max_output_chars: int,
245
+ mcp_server_url: str | None,
246
+ mcp_server_name: str,
247
+ trust_mcp_server: bool,
248
+ ) -> tuple[bool, str]:
249
+ """Run CLI with prompt from temp file (for long prompts)."""
250
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
251
+ f.write(prompt)
252
+ temp_path = f.name
253
+
254
+ try:
255
+ env: dict[str, str] | None = None
256
+ cleanup_paths: list[Path] = []
257
+
258
+ if backend == "claude":
259
+ # Claude reads from stdin with -p flag
260
+ mcp_args: list[str] = []
261
+ if mcp_server_url:
262
+ config_path = _claude_mcp_config(mcp_server_url, mcp_server_name)
263
+ _track_cleanup(config_path, cleanup_paths)
264
+ mcp_args = ["--mcp-config", str(config_path), "--strict-mcp-config"]
265
+ cmd = ["claude", "-p", *mcp_args, "--dangerously-skip-permissions"]
266
+ stdin_data = prompt.encode("utf-8")
267
+ elif backend == "codex":
268
+ # Codex reads prompt from stdin when "-" is passed
269
+ overrides: list[str] = []
270
+ if mcp_server_url:
271
+ overrides = _codex_mcp_overrides(mcp_server_url, mcp_server_name, trust_mcp_server)
272
+ cmd = ["codex", *overrides, "exec", "--full-auto", "-"]
273
+ stdin_data = prompt.encode("utf-8")
274
+ elif backend == "gemini":
275
+ # Gemini: -y for yolo mode, pass prompt via stdin
276
+ if mcp_server_url:
277
+ env, settings_path = _gemini_env_for_mcp(
278
+ mcp_server_url, mcp_server_name, trust_mcp_server
279
+ )
280
+ _track_cleanup(settings_path, cleanup_paths)
281
+ cmd = ["gemini", "-y"]
282
+ stdin_data = prompt.encode("utf-8")
283
+ else:
284
+ return False, f"Unknown CLI backend: {backend}"
285
+
286
+ proc = await asyncio.create_subprocess_exec(
287
+ *cmd,
288
+ stdin=asyncio.subprocess.PIPE if stdin_data else None,
289
+ stdout=asyncio.subprocess.PIPE,
290
+ stderr=asyncio.subprocess.PIPE,
291
+ cwd=str(cwd) if cwd else None,
292
+ env=env,
293
+ )
294
+
295
+ try:
296
+ stdout, stderr = await asyncio.wait_for(
297
+ proc.communicate(input=stdin_data),
298
+ timeout=timeout
299
+ )
300
+ output = stdout.decode("utf-8", errors="replace")
301
+
302
+ if len(output) > max_output_chars:
303
+ output = output[:max_output_chars] + "\n...[truncated]"
304
+
305
+ if proc.returncode != 0:
306
+ err = stderr.decode("utf-8", errors="replace")
307
+ if output.strip():
308
+ return True, output
309
+ return False, f"CLI error (exit {proc.returncode}): {err[:1000]}"
310
+
311
+ return True, output
312
+ except asyncio.TimeoutError:
313
+ proc.kill()
314
+ await proc.wait()
315
+ return False, f"CLI timeout after {timeout}s"
316
+ finally:
317
+ for path in cleanup_paths:
318
+ try:
319
+ path.unlink()
320
+ except Exception:
321
+ pass
322
+ finally:
323
+ # Clean up temp file
324
+ try:
325
+ Path(temp_path).unlink()
326
+ except Exception:
327
+ pass