foundry-mcp 0.3.3__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 -0
- foundry_mcp/cli/__init__.py +80 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +633 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +652 -0
- foundry_mcp/cli/commands/session.py +479 -0
- foundry_mcp/cli/commands/specs.py +856 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +259 -0
- foundry_mcp/cli/flags.py +266 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +850 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1636 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/feature_flags.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/journal.py +694 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1350 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +123 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +317 -0
- foundry_mcp/core/prometheus.py +577 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +546 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
- foundry_mcp/core/prompts/plan_review.py +623 -0
- foundry_mcp/core/providers/__init__.py +225 -0
- foundry_mcp/core/providers/base.py +476 -0
- foundry_mcp/core/providers/claude.py +460 -0
- foundry_mcp/core/providers/codex.py +619 -0
- foundry_mcp/core/providers/cursor_agent.py +642 -0
- foundry_mcp/core/providers/detectors.py +488 -0
- foundry_mcp/core/providers/gemini.py +405 -0
- foundry_mcp/core/providers/opencode.py +616 -0
- foundry_mcp/core/providers/opencode_wrapper.js +302 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +729 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +934 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +1650 -0
- foundry_mcp/core/task.py +1289 -0
- foundry_mcp/core/testing.py +450 -0
- foundry_mcp/core/validation.py +2081 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +234 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +289 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +174 -0
- foundry_mcp/dashboard/views/overview.py +160 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/sdd-spec-schema.json +386 -0
- foundry_mcp/server.py +164 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +71 -0
- foundry_mcp/tools/unified/authoring.py +1487 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +198 -0
- foundry_mcp/tools/unified/environment.py +939 -0
- foundry_mcp/tools/unified/error.py +462 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +632 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +745 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +629 -0
- foundry_mcp/tools/unified/review.py +685 -0
- foundry_mcp/tools/unified/review_helpers.py +299 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +580 -0
- foundry_mcp/tools/unified/spec.py +808 -0
- foundry_mcp/tools/unified/task.py +2202 -0
- foundry_mcp/tools/unified/test.py +370 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.3.3.dist-info/METADATA +337 -0
- foundry_mcp-0.3.3.dist-info/RECORD +135 -0
- foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
- foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Shared review helpers for the unified review tool.
|
|
2
|
+
|
|
3
|
+
This module centralizes the reusable building blocks used by
|
|
4
|
+
`foundry_mcp.tools.unified.review` so the main router stays focused on
|
|
5
|
+
input validation and action dispatch.
|
|
6
|
+
|
|
7
|
+
The implementation is adapted from the legacy CLI review command helpers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from foundry_mcp.core.responses import (
|
|
20
|
+
ErrorCode,
|
|
21
|
+
ErrorType,
|
|
22
|
+
ai_no_provider_error,
|
|
23
|
+
ai_provider_error,
|
|
24
|
+
ai_provider_timeout_error,
|
|
25
|
+
error_response,
|
|
26
|
+
success_response,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Default AI consultation timeout
|
|
33
|
+
DEFAULT_AI_TIMEOUT = 120.0
|
|
34
|
+
|
|
35
|
+
# Review types supported by the unified `review(action="spec")` entrypoint.
|
|
36
|
+
REVIEW_TYPES = ["quick", "full", "security", "feasibility"]
|
|
37
|
+
|
|
38
|
+
# Map review types to PLAN_REVIEW templates
|
|
39
|
+
_REVIEW_TYPE_TO_TEMPLATE = {
|
|
40
|
+
"full": "PLAN_REVIEW_FULL_V1",
|
|
41
|
+
"security": "PLAN_REVIEW_SECURITY_V1",
|
|
42
|
+
"feasibility": "PLAN_REVIEW_FEASIBILITY_V1",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_llm_status() -> Dict[str, Any]:
|
|
47
|
+
"""Get LLM configuration status.
|
|
48
|
+
|
|
49
|
+
This is a lightweight wrapper around `foundry_mcp.core.review.get_llm_status`
|
|
50
|
+
that normalizes exception handling for tool surfaces.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from foundry_mcp.core.review import get_llm_status
|
|
55
|
+
|
|
56
|
+
return get_llm_status()
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
logger.debug("Failed to get LLM status: %s", exc)
|
|
59
|
+
return {"configured": False, "error": "Failed to load LLM configuration"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run_quick_review(
|
|
63
|
+
*,
|
|
64
|
+
spec_id: str,
|
|
65
|
+
specs_dir: Optional[Path],
|
|
66
|
+
dry_run: bool,
|
|
67
|
+
llm_status: Dict[str, Any],
|
|
68
|
+
start_time: float,
|
|
69
|
+
) -> dict:
|
|
70
|
+
if dry_run:
|
|
71
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
72
|
+
return asdict(
|
|
73
|
+
success_response(
|
|
74
|
+
spec_id=spec_id,
|
|
75
|
+
review_type="quick",
|
|
76
|
+
dry_run=True,
|
|
77
|
+
llm_status=llm_status,
|
|
78
|
+
message="Dry run - quick review skipped",
|
|
79
|
+
telemetry={"duration_ms": round(duration_ms, 2)},
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
from foundry_mcp.core.review import quick_review
|
|
85
|
+
|
|
86
|
+
result = quick_review(spec_id=spec_id, specs_dir=specs_dir)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
logger.exception("Quick review failed")
|
|
89
|
+
return asdict(
|
|
90
|
+
error_response(
|
|
91
|
+
f"Quick review failed: {exc}",
|
|
92
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
93
|
+
error_type=ErrorType.INTERNAL,
|
|
94
|
+
remediation="Check logs for details and retry.",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
99
|
+
|
|
100
|
+
payload = asdict(result)
|
|
101
|
+
payload["llm_status"] = llm_status
|
|
102
|
+
|
|
103
|
+
return asdict(
|
|
104
|
+
success_response(
|
|
105
|
+
**payload,
|
|
106
|
+
telemetry={"duration_ms": round(duration_ms, 2)},
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run_ai_review(
|
|
112
|
+
*,
|
|
113
|
+
spec_id: str,
|
|
114
|
+
specs_dir: Optional[Path],
|
|
115
|
+
review_type: str,
|
|
116
|
+
ai_provider: Optional[str],
|
|
117
|
+
model: Optional[str],
|
|
118
|
+
ai_timeout: float,
|
|
119
|
+
consultation_cache: bool,
|
|
120
|
+
dry_run: bool,
|
|
121
|
+
llm_status: Dict[str, Any],
|
|
122
|
+
start_time: float,
|
|
123
|
+
) -> dict:
|
|
124
|
+
template_id = _REVIEW_TYPE_TO_TEMPLATE.get(review_type)
|
|
125
|
+
if template_id is None:
|
|
126
|
+
return asdict(
|
|
127
|
+
error_response(
|
|
128
|
+
f"Unknown review type: {review_type}",
|
|
129
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
130
|
+
error_type=ErrorType.VALIDATION,
|
|
131
|
+
remediation=f"Use one of: {', '.join(_REVIEW_TYPE_TO_TEMPLATE.keys())}",
|
|
132
|
+
data={"review_type": review_type},
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
from foundry_mcp.core.review import prepare_review_context
|
|
138
|
+
|
|
139
|
+
context = prepare_review_context(
|
|
140
|
+
spec_id=spec_id,
|
|
141
|
+
specs_dir=specs_dir,
|
|
142
|
+
include_tasks=True,
|
|
143
|
+
include_journals=True,
|
|
144
|
+
)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.exception("Failed preparing review context")
|
|
147
|
+
return asdict(
|
|
148
|
+
error_response(
|
|
149
|
+
f"Failed preparing review context: {exc}",
|
|
150
|
+
error_code=ErrorCode.INTERNAL_ERROR,
|
|
151
|
+
error_type=ErrorType.INTERNAL,
|
|
152
|
+
remediation="Check logs for details and retry.",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if context is None:
|
|
157
|
+
return asdict(
|
|
158
|
+
error_response(
|
|
159
|
+
f"Specification '{spec_id}' not found",
|
|
160
|
+
error_code=ErrorCode.SPEC_NOT_FOUND,
|
|
161
|
+
error_type=ErrorType.NOT_FOUND,
|
|
162
|
+
remediation="Verify the spec_id and that the spec exists.",
|
|
163
|
+
data={"spec_id": spec_id},
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if dry_run:
|
|
168
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
169
|
+
return asdict(
|
|
170
|
+
success_response(
|
|
171
|
+
spec_id=spec_id,
|
|
172
|
+
title=context.title,
|
|
173
|
+
review_type=review_type,
|
|
174
|
+
template_id=template_id,
|
|
175
|
+
dry_run=True,
|
|
176
|
+
llm_status=llm_status,
|
|
177
|
+
ai_provider=ai_provider,
|
|
178
|
+
model=model,
|
|
179
|
+
consultation_cache=consultation_cache,
|
|
180
|
+
message=f"Dry run - {review_type} review would use template {template_id}",
|
|
181
|
+
stats={
|
|
182
|
+
"total_tasks": context.stats.totals.get("tasks", 0)
|
|
183
|
+
if context.stats
|
|
184
|
+
else 0,
|
|
185
|
+
},
|
|
186
|
+
telemetry={"duration_ms": round(duration_ms, 2)},
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
from foundry_mcp.core.ai_consultation import (
|
|
192
|
+
ConsultationOrchestrator,
|
|
193
|
+
ConsultationRequest,
|
|
194
|
+
ConsultationWorkflow,
|
|
195
|
+
)
|
|
196
|
+
except ImportError:
|
|
197
|
+
return asdict(
|
|
198
|
+
error_response(
|
|
199
|
+
"AI consultation layer not available",
|
|
200
|
+
error_code=ErrorCode.UNAVAILABLE,
|
|
201
|
+
error_type=ErrorType.UNAVAILABLE,
|
|
202
|
+
remediation="Ensure foundry_mcp.core.ai_consultation is installed.",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
orchestrator = ConsultationOrchestrator(default_timeout=ai_timeout)
|
|
207
|
+
|
|
208
|
+
if not orchestrator.is_available(provider_id=ai_provider):
|
|
209
|
+
return asdict(
|
|
210
|
+
ai_no_provider_error(
|
|
211
|
+
"AI-enhanced review requested but no providers available",
|
|
212
|
+
required_providers=[ai_provider] if ai_provider else None,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
spec_content = json.dumps(context.spec_data, indent=2)
|
|
217
|
+
|
|
218
|
+
request = ConsultationRequest(
|
|
219
|
+
workflow=ConsultationWorkflow.PLAN_REVIEW,
|
|
220
|
+
prompt_id=template_id,
|
|
221
|
+
context={
|
|
222
|
+
"spec_content": spec_content,
|
|
223
|
+
"spec_id": spec_id,
|
|
224
|
+
"title": context.title,
|
|
225
|
+
"review_type": review_type,
|
|
226
|
+
},
|
|
227
|
+
provider_id=ai_provider,
|
|
228
|
+
model=model,
|
|
229
|
+
timeout=ai_timeout,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
result = orchestrator.consult(request, use_cache=consultation_cache)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
error_lower = str(exc).lower()
|
|
236
|
+
if "timeout" in error_lower or "timed out" in error_lower:
|
|
237
|
+
return asdict(
|
|
238
|
+
ai_provider_timeout_error(
|
|
239
|
+
ai_provider or "unknown",
|
|
240
|
+
int(ai_timeout),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return asdict(ai_provider_error(ai_provider or "unknown", str(exc)))
|
|
245
|
+
|
|
246
|
+
from foundry_mcp.core.ai_consultation import ConsensusResult
|
|
247
|
+
|
|
248
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
249
|
+
|
|
250
|
+
is_consensus = isinstance(result, ConsensusResult)
|
|
251
|
+
if is_consensus:
|
|
252
|
+
primary = (
|
|
253
|
+
result.successful_responses[0] if result.successful_responses else None
|
|
254
|
+
)
|
|
255
|
+
provider_id = primary.provider_id if primary else None
|
|
256
|
+
model_used = primary.model_used if primary else None
|
|
257
|
+
cached = bool(primary.cache_hit) if primary else False
|
|
258
|
+
content = result.primary_content
|
|
259
|
+
consensus = {
|
|
260
|
+
"mode": "multi_model",
|
|
261
|
+
"total_providers": len(result.responses),
|
|
262
|
+
"successful_providers": len(result.successful_responses),
|
|
263
|
+
"failed_providers": len(result.failed_responses),
|
|
264
|
+
}
|
|
265
|
+
else:
|
|
266
|
+
provider_id = getattr(result, "provider_id", ai_provider)
|
|
267
|
+
model_used = getattr(result, "model_used", None)
|
|
268
|
+
cached = bool(getattr(result, "cache_hit", False))
|
|
269
|
+
content = getattr(result, "content", None)
|
|
270
|
+
consensus = {"mode": "single_model"}
|
|
271
|
+
|
|
272
|
+
total_tasks = context.stats.totals.get("tasks", 0) if context.stats else 0
|
|
273
|
+
completed_tasks = (
|
|
274
|
+
context.stats.status_counts.get("completed", 0) if context.stats else 0
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return asdict(
|
|
278
|
+
success_response(
|
|
279
|
+
spec_id=spec_id,
|
|
280
|
+
title=context.title,
|
|
281
|
+
review_type=review_type,
|
|
282
|
+
template_id=template_id,
|
|
283
|
+
llm_status=llm_status,
|
|
284
|
+
ai_provider=provider_id,
|
|
285
|
+
model=model_used,
|
|
286
|
+
consultation_cache=consultation_cache,
|
|
287
|
+
response=content,
|
|
288
|
+
cached=cached,
|
|
289
|
+
consensus=consensus,
|
|
290
|
+
stats={
|
|
291
|
+
"total_tasks": total_tasks,
|
|
292
|
+
"completed_tasks": completed_tasks,
|
|
293
|
+
"progress_percentage": context.progress.get("percentage", 0)
|
|
294
|
+
if context.progress
|
|
295
|
+
else 0,
|
|
296
|
+
},
|
|
297
|
+
telemetry={"duration_ms": round(duration_ms, 2)},
|
|
298
|
+
)
|
|
299
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Action routing utilities for unified MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, Dict, cast
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ActionRouterError(ValueError):
|
|
11
|
+
"""Raised when an unsupported action is requested."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, allowed_actions: Sequence[str]) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.allowed_actions = tuple(allowed_actions)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ActionDefinition:
|
|
20
|
+
"""Describe an action handler for a unified tool."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
handler: Callable[..., dict]
|
|
24
|
+
summary: str | None = None
|
|
25
|
+
aliases: Sequence[str] = ()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ActionRouter:
|
|
29
|
+
"""Route action requests to the correct handler."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
tool_name: str,
|
|
35
|
+
actions: Iterable[ActionDefinition] | Mapping[str, Callable[..., dict]],
|
|
36
|
+
case_sensitive: bool = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
if isinstance(actions, Mapping):
|
|
39
|
+
mapping_actions = cast(Mapping[str, Callable[..., dict]], actions)
|
|
40
|
+
normalized_actions = [
|
|
41
|
+
ActionDefinition(name=name, handler=handler)
|
|
42
|
+
for name, handler in mapping_actions.items()
|
|
43
|
+
]
|
|
44
|
+
else:
|
|
45
|
+
iterable_actions = cast(Iterable[ActionDefinition], actions)
|
|
46
|
+
normalized_actions = list(iterable_actions)
|
|
47
|
+
|
|
48
|
+
if not normalized_actions:
|
|
49
|
+
raise ValueError("ActionRouter requires at least one action")
|
|
50
|
+
|
|
51
|
+
self._tool_name = tool_name
|
|
52
|
+
self._case_sensitive = case_sensitive
|
|
53
|
+
self._handlers: Dict[str, Callable[..., dict]] = {}
|
|
54
|
+
self._canonical: Dict[str, str] = {}
|
|
55
|
+
self._summaries: Dict[str, str | None] = {}
|
|
56
|
+
|
|
57
|
+
for action_def in normalized_actions:
|
|
58
|
+
canonical_name = action_def.name
|
|
59
|
+
if not canonical_name:
|
|
60
|
+
raise ValueError("Action names must be non-empty strings")
|
|
61
|
+
|
|
62
|
+
names = [canonical_name, *(action_def.aliases or ())]
|
|
63
|
+
for name in names:
|
|
64
|
+
if not name:
|
|
65
|
+
continue
|
|
66
|
+
key = name if case_sensitive else name.lower()
|
|
67
|
+
if key in self._handlers:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Duplicate action alias '{name}' for tool '{tool_name}'"
|
|
70
|
+
)
|
|
71
|
+
self._handlers[key] = action_def.handler
|
|
72
|
+
self._canonical[key] = canonical_name
|
|
73
|
+
self._summaries[canonical_name] = action_def.summary
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def tool_name(self) -> str:
|
|
77
|
+
return self._tool_name
|
|
78
|
+
|
|
79
|
+
def allowed_actions(self) -> tuple[str, ...]:
|
|
80
|
+
return tuple(sorted(set(self._canonical.values())))
|
|
81
|
+
|
|
82
|
+
def dispatch(self, action: str | None, **kwargs) -> dict:
|
|
83
|
+
if not action:
|
|
84
|
+
raise ActionRouterError(
|
|
85
|
+
f"{self._tool_name} requires an action",
|
|
86
|
+
allowed_actions=self.allowed_actions(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
key = action if self._case_sensitive else action.lower()
|
|
90
|
+
handler = self._handlers.get(key)
|
|
91
|
+
if handler is None:
|
|
92
|
+
raise ActionRouterError(
|
|
93
|
+
f"Unsupported action '{action}' for {self._tool_name}",
|
|
94
|
+
allowed_actions=self.allowed_actions(),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return handler(**kwargs)
|
|
98
|
+
|
|
99
|
+
def describe(self) -> Dict[str, str | None]:
|
|
100
|
+
"""Return summaries for the canonical actions."""
|
|
101
|
+
|
|
102
|
+
return dict(self._summaries)
|