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.
Files changed (85) hide show
  1. foundry_mcp/__init__.py +7 -1
  2. foundry_mcp/cli/__init__.py +0 -13
  3. foundry_mcp/cli/commands/plan.py +10 -3
  4. foundry_mcp/cli/commands/review.py +19 -4
  5. foundry_mcp/cli/commands/session.py +1 -8
  6. foundry_mcp/cli/commands/specs.py +38 -208
  7. foundry_mcp/cli/context.py +39 -0
  8. foundry_mcp/cli/output.py +3 -3
  9. foundry_mcp/config.py +615 -11
  10. foundry_mcp/core/ai_consultation.py +146 -9
  11. foundry_mcp/core/batch_operations.py +1196 -0
  12. foundry_mcp/core/discovery.py +7 -7
  13. foundry_mcp/core/error_store.py +2 -2
  14. foundry_mcp/core/intake.py +933 -0
  15. foundry_mcp/core/llm_config.py +28 -2
  16. foundry_mcp/core/metrics_store.py +2 -2
  17. foundry_mcp/core/naming.py +25 -2
  18. foundry_mcp/core/progress.py +70 -0
  19. foundry_mcp/core/prometheus.py +0 -13
  20. foundry_mcp/core/prompts/fidelity_review.py +149 -4
  21. foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
  22. foundry_mcp/core/prompts/plan_review.py +5 -1
  23. foundry_mcp/core/providers/__init__.py +12 -0
  24. foundry_mcp/core/providers/base.py +39 -0
  25. foundry_mcp/core/providers/claude.py +51 -48
  26. foundry_mcp/core/providers/codex.py +70 -60
  27. foundry_mcp/core/providers/cursor_agent.py +25 -47
  28. foundry_mcp/core/providers/detectors.py +34 -7
  29. foundry_mcp/core/providers/gemini.py +69 -58
  30. foundry_mcp/core/providers/opencode.py +101 -47
  31. foundry_mcp/core/providers/package-lock.json +4 -4
  32. foundry_mcp/core/providers/package.json +1 -1
  33. foundry_mcp/core/providers/validation.py +128 -0
  34. foundry_mcp/core/research/__init__.py +68 -0
  35. foundry_mcp/core/research/memory.py +528 -0
  36. foundry_mcp/core/research/models.py +1220 -0
  37. foundry_mcp/core/research/providers/__init__.py +40 -0
  38. foundry_mcp/core/research/providers/base.py +242 -0
  39. foundry_mcp/core/research/providers/google.py +507 -0
  40. foundry_mcp/core/research/providers/perplexity.py +442 -0
  41. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  42. foundry_mcp/core/research/providers/tavily.py +383 -0
  43. foundry_mcp/core/research/workflows/__init__.py +25 -0
  44. foundry_mcp/core/research/workflows/base.py +298 -0
  45. foundry_mcp/core/research/workflows/chat.py +271 -0
  46. foundry_mcp/core/research/workflows/consensus.py +539 -0
  47. foundry_mcp/core/research/workflows/deep_research.py +4020 -0
  48. foundry_mcp/core/research/workflows/ideate.py +682 -0
  49. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  50. foundry_mcp/core/responses.py +690 -0
  51. foundry_mcp/core/spec.py +2439 -236
  52. foundry_mcp/core/task.py +1205 -31
  53. foundry_mcp/core/testing.py +512 -123
  54. foundry_mcp/core/validation.py +319 -43
  55. foundry_mcp/dashboard/components/charts.py +0 -57
  56. foundry_mcp/dashboard/launcher.py +11 -0
  57. foundry_mcp/dashboard/views/metrics.py +25 -35
  58. foundry_mcp/dashboard/views/overview.py +1 -65
  59. foundry_mcp/resources/specs.py +25 -25
  60. foundry_mcp/schemas/intake-schema.json +89 -0
  61. foundry_mcp/schemas/sdd-spec-schema.json +33 -5
  62. foundry_mcp/server.py +0 -14
  63. foundry_mcp/tools/unified/__init__.py +39 -18
  64. foundry_mcp/tools/unified/authoring.py +2371 -248
  65. foundry_mcp/tools/unified/documentation_helpers.py +69 -6
  66. foundry_mcp/tools/unified/environment.py +434 -32
  67. foundry_mcp/tools/unified/error.py +18 -1
  68. foundry_mcp/tools/unified/lifecycle.py +8 -0
  69. foundry_mcp/tools/unified/plan.py +133 -2
  70. foundry_mcp/tools/unified/provider.py +0 -40
  71. foundry_mcp/tools/unified/research.py +1283 -0
  72. foundry_mcp/tools/unified/review.py +374 -17
  73. foundry_mcp/tools/unified/review_helpers.py +16 -1
  74. foundry_mcp/tools/unified/server.py +9 -24
  75. foundry_mcp/tools/unified/spec.py +367 -0
  76. foundry_mcp/tools/unified/task.py +1664 -30
  77. foundry_mcp/tools/unified/test.py +69 -8
  78. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
  79. foundry_mcp-0.8.10.dist-info/RECORD +153 -0
  80. foundry_mcp/cli/flags.py +0 -266
  81. foundry_mcp/core/feature_flags.py +0 -592
  82. foundry_mcp-0.3.3.dist-info/RECORD +0 -135
  83. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
  84. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
  85. {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=GEMINI_MODELS,
177
- default_model="gemini-2.5-flash",
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 = self._ensure_model(model or metadata.default_model or self._first_model_id())
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 self._ensure_model(str(model_override))
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
- f"Gemini CLI exited with code {completed.returncode}",
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=OPENCODE_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
- self._model = self._ensure_model(model or metadata.default_model or self._first_model_id())
184
- self._server_process: Optional[subprocess.Popen[str]] = None
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(self, custom_env: Optional[Dict[str, str]]) -> Dict[str, str]:
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 metadata or use default."""
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 self._ensure_model(str(model_override))
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
- f"OpenCode wrapper exited with code {completed.returncode}",
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.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.164",
20
- "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.164.tgz",
21
- "integrity": "sha512-TG+bpgL3O4tU/vCOT0THaSL5wJoXc15ErS79NLrEzFj1Igq1a9Mhef3oYZae0zZOI/ZTl/VNswguUeqkBm41pg=="
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
  }
@@ -17,7 +17,7 @@
17
17
  "author": "Foundry MCP",
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
- "@opencode-ai/sdk": "^1.0.0"
20
+ "@opencode-ai/sdk": "^1.0.218"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=18.0.0"
@@ -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
+ ]