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.
- aleph/__init__.py +49 -0
- aleph/cache/__init__.py +6 -0
- aleph/cache/base.py +20 -0
- aleph/cache/memory.py +27 -0
- aleph/cli.py +1044 -0
- aleph/config.py +154 -0
- aleph/core.py +874 -0
- aleph/mcp/__init__.py +30 -0
- aleph/mcp/local_server.py +3527 -0
- aleph/mcp/server.py +20 -0
- aleph/prompts/__init__.py +5 -0
- aleph/prompts/system.py +45 -0
- aleph/providers/__init__.py +14 -0
- aleph/providers/anthropic.py +253 -0
- aleph/providers/base.py +59 -0
- aleph/providers/openai.py +224 -0
- aleph/providers/registry.py +22 -0
- aleph/repl/__init__.py +5 -0
- aleph/repl/helpers.py +1068 -0
- aleph/repl/sandbox.py +777 -0
- aleph/sub_query/__init__.py +166 -0
- aleph/sub_query/api_backend.py +166 -0
- aleph/sub_query/cli_backend.py +327 -0
- aleph/types.py +216 -0
- aleph/utils/__init__.py +6 -0
- aleph/utils/logging.py +79 -0
- aleph/utils/tokens.py +43 -0
- aleph_rlm-0.6.0.dist-info/METADATA +358 -0
- aleph_rlm-0.6.0.dist-info/RECORD +32 -0
- aleph_rlm-0.6.0.dist-info/WHEEL +4 -0
- aleph_rlm-0.6.0.dist-info/entry_points.txt +3 -0
- aleph_rlm-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|