foundry-mcp 0.3.3__py3-none-any.whl → 0.8.10__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.
- foundry_mcp/__init__.py +7 -1
- foundry_mcp/cli/__init__.py +0 -13
- foundry_mcp/cli/commands/plan.py +10 -3
- foundry_mcp/cli/commands/review.py +19 -4
- foundry_mcp/cli/commands/session.py +1 -8
- foundry_mcp/cli/commands/specs.py +38 -208
- foundry_mcp/cli/context.py +39 -0
- foundry_mcp/cli/output.py +3 -3
- foundry_mcp/config.py +615 -11
- foundry_mcp/core/ai_consultation.py +146 -9
- foundry_mcp/core/batch_operations.py +1196 -0
- foundry_mcp/core/discovery.py +7 -7
- foundry_mcp/core/error_store.py +2 -2
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/llm_config.py +28 -2
- foundry_mcp/core/metrics_store.py +2 -2
- foundry_mcp/core/naming.py +25 -2
- foundry_mcp/core/progress.py +70 -0
- foundry_mcp/core/prometheus.py +0 -13
- foundry_mcp/core/prompts/fidelity_review.py +149 -4
- foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
- foundry_mcp/core/prompts/plan_review.py +5 -1
- foundry_mcp/core/providers/__init__.py +12 -0
- foundry_mcp/core/providers/base.py +39 -0
- foundry_mcp/core/providers/claude.py +51 -48
- foundry_mcp/core/providers/codex.py +70 -60
- foundry_mcp/core/providers/cursor_agent.py +25 -47
- foundry_mcp/core/providers/detectors.py +34 -7
- foundry_mcp/core/providers/gemini.py +69 -58
- foundry_mcp/core/providers/opencode.py +101 -47
- foundry_mcp/core/providers/package-lock.json +4 -4
- foundry_mcp/core/providers/package.json +1 -1
- foundry_mcp/core/providers/validation.py +128 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1220 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4020 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/responses.py +690 -0
- foundry_mcp/core/spec.py +2439 -236
- foundry_mcp/core/task.py +1205 -31
- foundry_mcp/core/testing.py +512 -123
- foundry_mcp/core/validation.py +319 -43
- foundry_mcp/dashboard/components/charts.py +0 -57
- foundry_mcp/dashboard/launcher.py +11 -0
- foundry_mcp/dashboard/views/metrics.py +25 -35
- foundry_mcp/dashboard/views/overview.py +1 -65
- foundry_mcp/resources/specs.py +25 -25
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +33 -5
- foundry_mcp/server.py +0 -14
- foundry_mcp/tools/unified/__init__.py +39 -18
- foundry_mcp/tools/unified/authoring.py +2371 -248
- foundry_mcp/tools/unified/documentation_helpers.py +69 -6
- foundry_mcp/tools/unified/environment.py +434 -32
- foundry_mcp/tools/unified/error.py +18 -1
- foundry_mcp/tools/unified/lifecycle.py +8 -0
- foundry_mcp/tools/unified/plan.py +133 -2
- foundry_mcp/tools/unified/provider.py +0 -40
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +374 -17
- foundry_mcp/tools/unified/review_helpers.py +16 -1
- foundry_mcp/tools/unified/server.py +9 -24
- foundry_mcp/tools/unified/spec.py +367 -0
- foundry_mcp/tools/unified/task.py +1664 -30
- foundry_mcp/tools/unified/test.py +69 -8
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
- foundry_mcp-0.8.10.dist-info/RECORD +153 -0
- foundry_mcp/cli/flags.py +0 -266
- foundry_mcp/core/feature_flags.py +0 -592
- foundry_mcp-0.3.3.dist-info/RECORD +0 -135
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -14,10 +14,7 @@ import os
|
|
|
14
14
|
import subprocess
|
|
15
15
|
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
|
16
16
|
|
|
17
|
-
logger = logging.getLogger(__name__)
|
|
18
|
-
|
|
19
17
|
from .base import (
|
|
20
|
-
ModelDescriptor,
|
|
21
18
|
ProviderCapability,
|
|
22
19
|
ProviderContext,
|
|
23
20
|
ProviderExecutionError,
|
|
@@ -34,6 +31,8 @@ from .base import (
|
|
|
34
31
|
from .detectors import detect_provider_availability
|
|
35
32
|
from .registry import register_provider
|
|
36
33
|
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
37
36
|
DEFAULT_BINARY = "gemini"
|
|
38
37
|
DEFAULT_TIMEOUT_SECONDS = 360
|
|
39
38
|
AVAILABILITY_OVERRIDE_ENV = "GEMINI_CLI_AVAILABLE_OVERRIDE"
|
|
@@ -137,44 +136,11 @@ def _default_runner(
|
|
|
137
136
|
)
|
|
138
137
|
|
|
139
138
|
|
|
140
|
-
GEMINI_MODELS: List[ModelDescriptor] = [
|
|
141
|
-
ModelDescriptor(
|
|
142
|
-
id="pro",
|
|
143
|
-
display_name="Gemini 3.0 Pro",
|
|
144
|
-
capabilities={
|
|
145
|
-
ProviderCapability.TEXT,
|
|
146
|
-
ProviderCapability.STREAMING,
|
|
147
|
-
ProviderCapability.VISION,
|
|
148
|
-
},
|
|
149
|
-
routing_hints={"tier": "pro", "context_window": "1M"},
|
|
150
|
-
),
|
|
151
|
-
ModelDescriptor(
|
|
152
|
-
id="gemini-2.5-pro",
|
|
153
|
-
display_name="Gemini 2.5 Pro",
|
|
154
|
-
capabilities={
|
|
155
|
-
ProviderCapability.TEXT,
|
|
156
|
-
ProviderCapability.STREAMING,
|
|
157
|
-
ProviderCapability.VISION,
|
|
158
|
-
},
|
|
159
|
-
routing_hints={"tier": "pro", "context_window": "1M"},
|
|
160
|
-
),
|
|
161
|
-
ModelDescriptor(
|
|
162
|
-
id="gemini-2.5-flash",
|
|
163
|
-
display_name="Gemini 2.5 Flash",
|
|
164
|
-
capabilities={
|
|
165
|
-
ProviderCapability.TEXT,
|
|
166
|
-
ProviderCapability.STREAMING,
|
|
167
|
-
ProviderCapability.VISION,
|
|
168
|
-
},
|
|
169
|
-
routing_hints={"tier": "flash"},
|
|
170
|
-
),
|
|
171
|
-
]
|
|
172
|
-
|
|
173
139
|
GEMINI_METADATA = ProviderMetadata(
|
|
174
140
|
provider_id="gemini",
|
|
175
141
|
display_name="Google Gemini CLI",
|
|
176
|
-
models=
|
|
177
|
-
default_model="
|
|
142
|
+
models=[], # Model validation delegated to CLI
|
|
143
|
+
default_model="pro",
|
|
178
144
|
capabilities={ProviderCapability.TEXT, ProviderCapability.STREAMING, ProviderCapability.VISION},
|
|
179
145
|
security_flags={"writes_allowed": False},
|
|
180
146
|
extra={"cli": "gemini", "output_format": "json"},
|
|
@@ -200,24 +166,7 @@ class GeminiProvider(ProviderContext):
|
|
|
200
166
|
self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
|
|
201
167
|
self._env = env
|
|
202
168
|
self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
|
|
203
|
-
self._model =
|
|
204
|
-
|
|
205
|
-
def _first_model_id(self) -> str:
|
|
206
|
-
if not self.metadata.models:
|
|
207
|
-
raise ProviderUnavailableError(
|
|
208
|
-
"Gemini provider metadata is missing model descriptors.",
|
|
209
|
-
provider=self.metadata.provider_id,
|
|
210
|
-
)
|
|
211
|
-
return self.metadata.models[0].id
|
|
212
|
-
|
|
213
|
-
def _ensure_model(self, candidate: str) -> str:
|
|
214
|
-
available = {descriptor.id for descriptor in self.metadata.models}
|
|
215
|
-
if candidate not in available:
|
|
216
|
-
raise ProviderExecutionError(
|
|
217
|
-
f"Unsupported Gemini model '{candidate}'. Available: {', '.join(sorted(available))}",
|
|
218
|
-
provider=self.metadata.provider_id,
|
|
219
|
-
)
|
|
220
|
-
return candidate
|
|
169
|
+
self._model = model or metadata.default_model or "pro"
|
|
221
170
|
|
|
222
171
|
def _validate_request(self, request: ProviderRequest) -> None:
|
|
223
172
|
"""Validate and normalize request, ignoring unsupported parameters."""
|
|
@@ -303,9 +252,14 @@ class GeminiProvider(ProviderContext):
|
|
|
303
252
|
)
|
|
304
253
|
|
|
305
254
|
def _resolve_model(self, request: ProviderRequest) -> str:
|
|
255
|
+
# 1. Check request.model first (from ProviderRequest constructor)
|
|
256
|
+
if request.model:
|
|
257
|
+
return str(request.model)
|
|
258
|
+
# 2. Fallback to metadata override (legacy/alternative path)
|
|
306
259
|
model_override = request.metadata.get("model") if request.metadata else None
|
|
307
260
|
if model_override:
|
|
308
|
-
return
|
|
261
|
+
return str(model_override)
|
|
262
|
+
# 3. Fallback to instance default
|
|
309
263
|
return self._model
|
|
310
264
|
|
|
311
265
|
def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
|
|
@@ -313,6 +267,54 @@ class GeminiProvider(ProviderContext):
|
|
|
313
267
|
return
|
|
314
268
|
self._emit_stream_chunk(StreamChunk(content=content, index=0))
|
|
315
269
|
|
|
270
|
+
def _extract_error_from_output(self, stdout: str) -> Optional[str]:
|
|
271
|
+
"""
|
|
272
|
+
Extract error message from Gemini CLI output.
|
|
273
|
+
|
|
274
|
+
Gemini CLI outputs errors as text lines followed by JSON. Example:
|
|
275
|
+
'Error when talking to Gemini API Full report available at: /tmp/...
|
|
276
|
+
{"error": {"type": "Error", "message": "[object Object]", "code": 1}}'
|
|
277
|
+
|
|
278
|
+
The JSON message field is often unhelpful ("[object Object]"), so we
|
|
279
|
+
prefer the text prefix which contains the actual error description.
|
|
280
|
+
"""
|
|
281
|
+
if not stdout:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
lines = stdout.strip().split("\n")
|
|
285
|
+
error_parts: List[str] = []
|
|
286
|
+
|
|
287
|
+
for line in lines:
|
|
288
|
+
line = line.strip()
|
|
289
|
+
if not line:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
# Skip "Loaded cached credentials" info line
|
|
293
|
+
if line.startswith("Loaded cached"):
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# Try to parse as JSON
|
|
297
|
+
if line.startswith("{"):
|
|
298
|
+
try:
|
|
299
|
+
payload = json.loads(line)
|
|
300
|
+
error = payload.get("error", {})
|
|
301
|
+
if isinstance(error, dict):
|
|
302
|
+
msg = error.get("message", "")
|
|
303
|
+
# Skip unhelpful "[object Object]" message
|
|
304
|
+
if msg and msg != "[object Object]":
|
|
305
|
+
error_parts.append(msg)
|
|
306
|
+
except json.JSONDecodeError:
|
|
307
|
+
pass
|
|
308
|
+
else:
|
|
309
|
+
# Text line - likely contains the actual error message
|
|
310
|
+
# Extract the part before "Full report available at:"
|
|
311
|
+
if "Full report available at:" in line:
|
|
312
|
+
line = line.split("Full report available at:")[0].strip()
|
|
313
|
+
if line:
|
|
314
|
+
error_parts.append(line)
|
|
315
|
+
|
|
316
|
+
return "; ".join(error_parts) if error_parts else None
|
|
317
|
+
|
|
316
318
|
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
317
319
|
self._validate_request(request)
|
|
318
320
|
model = self._resolve_model(request)
|
|
@@ -324,8 +326,17 @@ class GeminiProvider(ProviderContext):
|
|
|
324
326
|
if completed.returncode != 0:
|
|
325
327
|
stderr = (completed.stderr or "").strip()
|
|
326
328
|
logger.debug(f"Gemini CLI stderr: {stderr or 'no stderr'}")
|
|
329
|
+
|
|
330
|
+
# Extract error from stdout (Gemini outputs errors as text + JSON)
|
|
331
|
+
stdout_error = self._extract_error_from_output(completed.stdout)
|
|
332
|
+
|
|
333
|
+
error_msg = f"Gemini CLI exited with code {completed.returncode}"
|
|
334
|
+
if stdout_error:
|
|
335
|
+
error_msg += f": {stdout_error[:500]}"
|
|
336
|
+
elif stderr:
|
|
337
|
+
error_msg += f": {stderr[:500]}"
|
|
327
338
|
raise ProviderExecutionError(
|
|
328
|
-
|
|
339
|
+
error_msg,
|
|
329
340
|
provider=self.metadata.provider_id,
|
|
330
341
|
)
|
|
331
342
|
|
|
@@ -18,10 +18,7 @@ import time
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
|
20
20
|
|
|
21
|
-
logger = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
21
|
from .base import (
|
|
24
|
-
ModelDescriptor,
|
|
25
22
|
ProviderCapability,
|
|
26
23
|
ProviderContext,
|
|
27
24
|
ProviderExecutionError,
|
|
@@ -38,6 +35,8 @@ from .base import (
|
|
|
38
35
|
from .detectors import detect_provider_availability
|
|
39
36
|
from .registry import register_provider
|
|
40
37
|
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
41
40
|
DEFAULT_BINARY = "node"
|
|
42
41
|
DEFAULT_WRAPPER_SCRIPT = Path(__file__).parent / "opencode_wrapper.js"
|
|
43
42
|
DEFAULT_TIMEOUT_SECONDS = 360
|
|
@@ -122,26 +121,10 @@ def _default_runner(
|
|
|
122
121
|
)
|
|
123
122
|
|
|
124
123
|
|
|
125
|
-
OPENCODE_MODELS: List[ModelDescriptor] = [
|
|
126
|
-
ModelDescriptor(
|
|
127
|
-
id="openai/gpt-5.1-codex-mini",
|
|
128
|
-
display_name="OpenAI GPT-5.1 Codex Mini (via OpenCode)",
|
|
129
|
-
capabilities={
|
|
130
|
-
ProviderCapability.TEXT,
|
|
131
|
-
ProviderCapability.STREAMING,
|
|
132
|
-
},
|
|
133
|
-
routing_hints={
|
|
134
|
-
"configurable": True,
|
|
135
|
-
"source": "opencode config",
|
|
136
|
-
"note": "Accepts any model ID - validated by opencode CLI",
|
|
137
|
-
},
|
|
138
|
-
),
|
|
139
|
-
]
|
|
140
|
-
|
|
141
124
|
OPENCODE_METADATA = ProviderMetadata(
|
|
142
125
|
provider_id="opencode",
|
|
143
126
|
display_name="OpenCode AI SDK",
|
|
144
|
-
models=
|
|
127
|
+
models=[], # Model validation delegated to CLI
|
|
145
128
|
default_model="openai/gpt-5.1-codex-mini",
|
|
146
129
|
capabilities={ProviderCapability.TEXT, ProviderCapability.STREAMING},
|
|
147
130
|
security_flags={"writes_allowed": False, "read_only": True},
|
|
@@ -180,8 +163,17 @@ class OpenCodeProvider(ProviderContext):
|
|
|
180
163
|
self._env = self._prepare_subprocess_env(env)
|
|
181
164
|
|
|
182
165
|
self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
|
|
183
|
-
|
|
184
|
-
|
|
166
|
+
|
|
167
|
+
# Validate model - reject empty or whitespace-only strings
|
|
168
|
+
effective_model = model or metadata.default_model or "openai/gpt-5.1-codex-mini"
|
|
169
|
+
if not effective_model or not effective_model.strip():
|
|
170
|
+
raise ProviderExecutionError(
|
|
171
|
+
"Model identifier cannot be empty",
|
|
172
|
+
provider="opencode",
|
|
173
|
+
)
|
|
174
|
+
self._model = effective_model
|
|
175
|
+
|
|
176
|
+
self._server_process: Optional[subprocess.Popen[bytes]] = None
|
|
185
177
|
self._config_file_path: Optional[Path] = None
|
|
186
178
|
|
|
187
179
|
def __del__(self) -> None:
|
|
@@ -205,7 +197,9 @@ class OpenCodeProvider(ProviderContext):
|
|
|
205
197
|
# Clean up config file
|
|
206
198
|
self._cleanup_config_file()
|
|
207
199
|
|
|
208
|
-
def _prepare_subprocess_env(
|
|
200
|
+
def _prepare_subprocess_env(
|
|
201
|
+
self, custom_env: Optional[Dict[str, str]]
|
|
202
|
+
) -> Dict[str, str]:
|
|
209
203
|
"""
|
|
210
204
|
Prepare environment variables for subprocess execution.
|
|
211
205
|
|
|
@@ -226,8 +220,51 @@ class OpenCodeProvider(ProviderContext):
|
|
|
226
220
|
# Note: OPENCODE_API_KEY should be provided via environment or custom_env
|
|
227
221
|
# We don't set a default value for security reasons
|
|
228
222
|
|
|
223
|
+
# Add global npm modules to NODE_PATH so wrapper can find @opencode-ai/sdk
|
|
224
|
+
# This allows the SDK to be installed globally rather than bundled
|
|
225
|
+
self._ensure_node_path(subprocess_env)
|
|
226
|
+
|
|
229
227
|
return subprocess_env
|
|
230
228
|
|
|
229
|
+
def _ensure_node_path(self, env: Dict[str, str]) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Ensure NODE_PATH includes global npm modules and local node_modules.
|
|
232
|
+
|
|
233
|
+
This allows the wrapper script to import @opencode-ai/sdk whether it's
|
|
234
|
+
installed globally (npm install -g @opencode-ai/sdk) or locally in the
|
|
235
|
+
providers directory.
|
|
236
|
+
"""
|
|
237
|
+
node_paths: List[str] = []
|
|
238
|
+
|
|
239
|
+
# Add existing NODE_PATH entries
|
|
240
|
+
if env.get("NODE_PATH"):
|
|
241
|
+
node_paths.extend(env["NODE_PATH"].split(os.pathsep))
|
|
242
|
+
|
|
243
|
+
# Add local node_modules (alongside wrapper script)
|
|
244
|
+
local_node_modules = self._wrapper_path.parent / "node_modules"
|
|
245
|
+
if local_node_modules.exists():
|
|
246
|
+
node_paths.append(str(local_node_modules))
|
|
247
|
+
|
|
248
|
+
# Detect and add global npm root
|
|
249
|
+
try:
|
|
250
|
+
result = subprocess.run(
|
|
251
|
+
["npm", "root", "-g"],
|
|
252
|
+
capture_output=True,
|
|
253
|
+
text=True,
|
|
254
|
+
timeout=5,
|
|
255
|
+
check=False,
|
|
256
|
+
)
|
|
257
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
258
|
+
global_root = result.stdout.strip()
|
|
259
|
+
if global_root not in node_paths:
|
|
260
|
+
node_paths.append(global_root)
|
|
261
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
262
|
+
# npm not available or timed out - skip global path
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
if node_paths:
|
|
266
|
+
env["NODE_PATH"] = os.pathsep.join(node_paths)
|
|
267
|
+
|
|
231
268
|
def _create_readonly_config(self) -> Path:
|
|
232
269
|
"""
|
|
233
270
|
Create temporary opencode.json with read-only tool restrictions.
|
|
@@ -267,27 +304,6 @@ class OpenCodeProvider(ProviderContext):
|
|
|
267
304
|
finally:
|
|
268
305
|
self._config_file_path = None
|
|
269
306
|
|
|
270
|
-
def _first_model_id(self) -> str:
|
|
271
|
-
if not self.metadata.models:
|
|
272
|
-
raise ProviderUnavailableError(
|
|
273
|
-
"OpenCode provider metadata is missing model descriptors.",
|
|
274
|
-
provider=self.metadata.provider_id,
|
|
275
|
-
)
|
|
276
|
-
return self.metadata.models[0].id
|
|
277
|
-
|
|
278
|
-
def _ensure_model(self, candidate: str) -> str:
|
|
279
|
-
# Validate that the model is not empty
|
|
280
|
-
if not candidate or not candidate.strip():
|
|
281
|
-
raise ProviderExecutionError(
|
|
282
|
-
"Model identifier cannot be empty",
|
|
283
|
-
provider=self.metadata.provider_id,
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
# For opencode, we accept any model ID and let opencode CLI validate it
|
|
287
|
-
# This avoids maintaining a hardcoded list that would become stale
|
|
288
|
-
# opencode CLI supports many models across providers (OpenAI, Anthropic, etc.)
|
|
289
|
-
return candidate
|
|
290
|
-
|
|
291
307
|
def _is_port_open(self, port: int, host: str = "localhost") -> bool:
|
|
292
308
|
"""Check if a TCP port is open and accepting connections."""
|
|
293
309
|
try:
|
|
@@ -407,10 +423,15 @@ class OpenCodeProvider(ProviderContext):
|
|
|
407
423
|
return request.prompt
|
|
408
424
|
|
|
409
425
|
def _resolve_model(self, request: ProviderRequest) -> str:
|
|
410
|
-
"""Resolve model from request
|
|
426
|
+
"""Resolve model from request or use default."""
|
|
427
|
+
# 1. Check request.model first (from ProviderRequest constructor)
|
|
428
|
+
if request.model:
|
|
429
|
+
return str(request.model)
|
|
430
|
+
# 2. Fallback to metadata override (legacy/alternative path)
|
|
411
431
|
model_override = request.metadata.get("model") if request.metadata else None
|
|
412
432
|
if model_override:
|
|
413
|
-
return
|
|
433
|
+
return str(model_override)
|
|
434
|
+
# 3. Fallback to instance default
|
|
414
435
|
return self._model
|
|
415
436
|
|
|
416
437
|
def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
|
|
@@ -419,6 +440,30 @@ class OpenCodeProvider(ProviderContext):
|
|
|
419
440
|
return
|
|
420
441
|
self._emit_stream_chunk(StreamChunk(content=content, index=0))
|
|
421
442
|
|
|
443
|
+
def _extract_error_from_jsonl(self, stdout: str) -> Optional[str]:
|
|
444
|
+
"""
|
|
445
|
+
Extract error message from OpenCode wrapper JSONL output.
|
|
446
|
+
|
|
447
|
+
The wrapper outputs errors as {"type":"error","code":"...","message":"..."}.
|
|
448
|
+
"""
|
|
449
|
+
if not stdout:
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
for line in stdout.strip().split("\n"):
|
|
453
|
+
if not line.strip():
|
|
454
|
+
continue
|
|
455
|
+
try:
|
|
456
|
+
event = json.loads(line)
|
|
457
|
+
except json.JSONDecodeError:
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
if event.get("type") == "error":
|
|
461
|
+
msg = event.get("message", "")
|
|
462
|
+
if msg:
|
|
463
|
+
return msg
|
|
464
|
+
|
|
465
|
+
return None
|
|
466
|
+
|
|
422
467
|
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
423
468
|
"""Execute generation request via OpenCode wrapper."""
|
|
424
469
|
self._validate_request(request)
|
|
@@ -467,8 +512,17 @@ class OpenCodeProvider(ProviderContext):
|
|
|
467
512
|
if completed.returncode != 0:
|
|
468
513
|
stderr = (completed.stderr or "").strip()
|
|
469
514
|
logger.debug(f"OpenCode wrapper stderr: {stderr or 'no stderr'}")
|
|
515
|
+
|
|
516
|
+
# Extract error from JSONL stdout (wrapper outputs {"type":"error","message":"..."})
|
|
517
|
+
jsonl_error = self._extract_error_from_jsonl(completed.stdout)
|
|
518
|
+
|
|
519
|
+
error_msg = f"OpenCode wrapper exited with code {completed.returncode}"
|
|
520
|
+
if jsonl_error:
|
|
521
|
+
error_msg += f": {jsonl_error[:500]}"
|
|
522
|
+
elif stderr:
|
|
523
|
+
error_msg += f": {stderr[:500]}"
|
|
470
524
|
raise ProviderExecutionError(
|
|
471
|
-
|
|
525
|
+
error_msg,
|
|
472
526
|
provider=self.metadata.provider_id,
|
|
473
527
|
)
|
|
474
528
|
|
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
"version": "0.1.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@opencode-ai/sdk": "^1.0.
|
|
12
|
+
"@opencode-ai/sdk": "^1.0.218"
|
|
13
13
|
},
|
|
14
14
|
"engines": {
|
|
15
15
|
"node": ">=18.0.0"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"node_modules/@opencode-ai/sdk": {
|
|
19
|
-
"version": "1.0.
|
|
20
|
-
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.
|
|
21
|
-
"integrity": "sha512-
|
|
19
|
+
"version": "1.0.218",
|
|
20
|
+
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.218.tgz",
|
|
21
|
+
"integrity": "sha512-c6ss6UPAMskSVQUecuhNvPLFngyVh2Os9o0kpVjoqJJ16HXhzjVSk5axgh3ueQrfP5aZfg5o6l6srmjuCTPNnQ=="
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
}
|
|
@@ -696,6 +696,129 @@ def with_validation_and_resilience(
|
|
|
696
696
|
return decorator
|
|
697
697
|
|
|
698
698
|
|
|
699
|
+
# ---------------------------------------------------------------------------
|
|
700
|
+
# Context Window Error Detection
|
|
701
|
+
# ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
# Common error patterns indicating context window/token limit exceeded
|
|
704
|
+
CONTEXT_WINDOW_ERROR_PATTERNS: Set[str] = {
|
|
705
|
+
# OpenAI patterns
|
|
706
|
+
"context_length_exceeded",
|
|
707
|
+
"maximum context length",
|
|
708
|
+
"max_tokens",
|
|
709
|
+
"token limit",
|
|
710
|
+
"tokens exceeds",
|
|
711
|
+
"prompt is too long",
|
|
712
|
+
"input too long",
|
|
713
|
+
# Anthropic patterns
|
|
714
|
+
"prompt is too large",
|
|
715
|
+
"context window",
|
|
716
|
+
"exceeds the maximum",
|
|
717
|
+
"too many tokens",
|
|
718
|
+
# Google/Gemini patterns
|
|
719
|
+
"max input tokens",
|
|
720
|
+
"input token limit",
|
|
721
|
+
"content is too long",
|
|
722
|
+
"request payload size exceeds",
|
|
723
|
+
# Generic patterns
|
|
724
|
+
"length exceeded",
|
|
725
|
+
"limit exceeded",
|
|
726
|
+
"too long for model",
|
|
727
|
+
"input exceeds",
|
|
728
|
+
"context limit",
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def is_context_window_error(error: Exception) -> bool:
|
|
733
|
+
"""Check if an exception indicates a context window/token limit error.
|
|
734
|
+
|
|
735
|
+
Examines the error message for common patterns indicating the prompt
|
|
736
|
+
exceeded the model's context window or token limit.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
error: Exception to check
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
True if the error appears to be a context window error
|
|
743
|
+
"""
|
|
744
|
+
error_str = str(error).lower()
|
|
745
|
+
|
|
746
|
+
for pattern in CONTEXT_WINDOW_ERROR_PATTERNS:
|
|
747
|
+
if pattern in error_str:
|
|
748
|
+
return True
|
|
749
|
+
|
|
750
|
+
return False
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def extract_token_counts(error_str: str) -> tuple[Optional[int], Optional[int]]:
|
|
754
|
+
"""Extract token counts from error message if present.
|
|
755
|
+
|
|
756
|
+
Attempts to parse prompt_tokens and max_tokens from common error formats.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
error_str: Error message string
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
Tuple of (prompt_tokens, max_tokens), either may be None if not found
|
|
763
|
+
"""
|
|
764
|
+
import re
|
|
765
|
+
|
|
766
|
+
prompt_tokens = None
|
|
767
|
+
max_tokens = None
|
|
768
|
+
|
|
769
|
+
# Pattern: "X tokens exceeds Y limit" or "X exceeds Y"
|
|
770
|
+
match = re.search(r"(\d{1,7})\s*tokens?\s*exceeds?\s*(?:the\s*)?(\d{1,7})", error_str.lower())
|
|
771
|
+
if match:
|
|
772
|
+
prompt_tokens = int(match.group(1))
|
|
773
|
+
max_tokens = int(match.group(2))
|
|
774
|
+
return prompt_tokens, max_tokens
|
|
775
|
+
|
|
776
|
+
# Pattern: "maximum context length is X tokens" with "Y tokens" input
|
|
777
|
+
max_match = re.search(r"maximum\s+(?:context\s+)?length\s+(?:is\s+)?(\d{1,7})", error_str.lower())
|
|
778
|
+
if max_match:
|
|
779
|
+
max_tokens = int(max_match.group(1))
|
|
780
|
+
|
|
781
|
+
# Pattern: "requested X tokens" or "contains X tokens"
|
|
782
|
+
prompt_match = re.search(r"(?:requested|contains|have|with)\s+(\d{1,7})\s*tokens?", error_str.lower())
|
|
783
|
+
if prompt_match:
|
|
784
|
+
prompt_tokens = int(prompt_match.group(1))
|
|
785
|
+
|
|
786
|
+
return prompt_tokens, max_tokens
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def create_context_window_guidance(
|
|
790
|
+
prompt_tokens: Optional[int] = None,
|
|
791
|
+
max_tokens: Optional[int] = None,
|
|
792
|
+
provider_id: Optional[str] = None,
|
|
793
|
+
) -> str:
|
|
794
|
+
"""Generate actionable guidance for resolving context window errors.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
prompt_tokens: Number of tokens in the prompt (if known)
|
|
798
|
+
max_tokens: Maximum tokens allowed (if known)
|
|
799
|
+
provider_id: Provider that raised the error
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
Human-readable guidance string
|
|
803
|
+
"""
|
|
804
|
+
parts = ["Context window limit exceeded."]
|
|
805
|
+
|
|
806
|
+
if prompt_tokens and max_tokens:
|
|
807
|
+
overflow = prompt_tokens - max_tokens
|
|
808
|
+
parts.append(f"Prompt ({prompt_tokens:,} tokens) exceeds limit ({max_tokens:,} tokens) by {overflow:,} tokens.")
|
|
809
|
+
elif prompt_tokens:
|
|
810
|
+
parts.append(f"Prompt contains approximately {prompt_tokens:,} tokens.")
|
|
811
|
+
elif max_tokens:
|
|
812
|
+
parts.append(f"Maximum context window is {max_tokens:,} tokens.")
|
|
813
|
+
|
|
814
|
+
parts.append("To resolve: (1) Reduce input size by excluding large content, "
|
|
815
|
+
"(2) Summarize or truncate long sections, "
|
|
816
|
+
"(3) Use a model with larger context window, "
|
|
817
|
+
"(4) Process content in smaller batches.")
|
|
818
|
+
|
|
819
|
+
return " ".join(parts)
|
|
820
|
+
|
|
821
|
+
|
|
699
822
|
__all__ = [
|
|
700
823
|
# Validation
|
|
701
824
|
"ValidationError",
|
|
@@ -726,4 +849,9 @@ __all__ = [
|
|
|
726
849
|
"reset_rate_limiters",
|
|
727
850
|
# Execution wrapper
|
|
728
851
|
"with_validation_and_resilience",
|
|
852
|
+
# Context window detection
|
|
853
|
+
"CONTEXT_WINDOW_ERROR_PATTERNS",
|
|
854
|
+
"is_context_window_error",
|
|
855
|
+
"extract_token_counts",
|
|
856
|
+
"create_context_window_guidance",
|
|
729
857
|
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Research workflows for multi-model orchestration.
|
|
2
|
+
|
|
3
|
+
This package provides conversation threading, multi-model consensus,
|
|
4
|
+
hypothesis-driven investigation, and creative brainstorming workflows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from foundry_mcp.core.research.models import (
|
|
8
|
+
ConfidenceLevel,
|
|
9
|
+
ConsensusConfig,
|
|
10
|
+
ConsensusState,
|
|
11
|
+
ConsensusStrategy,
|
|
12
|
+
ConversationMessage,
|
|
13
|
+
ConversationThread,
|
|
14
|
+
Hypothesis,
|
|
15
|
+
Idea,
|
|
16
|
+
IdeaCluster,
|
|
17
|
+
IdeationPhase,
|
|
18
|
+
IdeationState,
|
|
19
|
+
InvestigationStep,
|
|
20
|
+
ModelResponse,
|
|
21
|
+
ThreadStatus,
|
|
22
|
+
ThinkDeepState,
|
|
23
|
+
WorkflowType,
|
|
24
|
+
)
|
|
25
|
+
from foundry_mcp.core.research.memory import (
|
|
26
|
+
FileStorageBackend,
|
|
27
|
+
ResearchMemory,
|
|
28
|
+
)
|
|
29
|
+
from foundry_mcp.core.research.workflows import (
|
|
30
|
+
ChatWorkflow,
|
|
31
|
+
ConsensusWorkflow,
|
|
32
|
+
IdeateWorkflow,
|
|
33
|
+
ResearchWorkflowBase,
|
|
34
|
+
ThinkDeepWorkflow,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Enums
|
|
39
|
+
"WorkflowType",
|
|
40
|
+
"ConfidenceLevel",
|
|
41
|
+
"ConsensusStrategy",
|
|
42
|
+
"ThreadStatus",
|
|
43
|
+
"IdeationPhase",
|
|
44
|
+
# Conversation models
|
|
45
|
+
"ConversationMessage",
|
|
46
|
+
"ConversationThread",
|
|
47
|
+
# THINKDEEP models
|
|
48
|
+
"Hypothesis",
|
|
49
|
+
"InvestigationStep",
|
|
50
|
+
"ThinkDeepState",
|
|
51
|
+
# IDEATE models
|
|
52
|
+
"Idea",
|
|
53
|
+
"IdeaCluster",
|
|
54
|
+
"IdeationState",
|
|
55
|
+
# CONSENSUS models
|
|
56
|
+
"ModelResponse",
|
|
57
|
+
"ConsensusConfig",
|
|
58
|
+
"ConsensusState",
|
|
59
|
+
# Storage
|
|
60
|
+
"FileStorageBackend",
|
|
61
|
+
"ResearchMemory",
|
|
62
|
+
# Workflows
|
|
63
|
+
"ResearchWorkflowBase",
|
|
64
|
+
"ChatWorkflow",
|
|
65
|
+
"ConsensusWorkflow",
|
|
66
|
+
"ThinkDeepWorkflow",
|
|
67
|
+
"IdeateWorkflow",
|
|
68
|
+
]
|