foo-mcp 0.1.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.
- foo_mcp/__init__.py +12 -0
- foo_mcp/_data/__init__.py +0 -0
- foo_mcp/_data/foo.agents.example.json +65 -0
- foo_mcp/config.py +290 -0
- foo_mcp/consensus_demo.py +291 -0
- foo_mcp/init_cli.py +606 -0
- foo_mcp/orchestrator.py +398 -0
- foo_mcp/providers/__init__.py +29 -0
- foo_mcp/providers/anthropic.py +50 -0
- foo_mcp/providers/base.py +108 -0
- foo_mcp/providers/google.py +51 -0
- foo_mcp/providers/huggingface.py +92 -0
- foo_mcp/providers/mlx.py +137 -0
- foo_mcp/providers/mock.py +32 -0
- foo_mcp/providers/openai.py +68 -0
- foo_mcp/schemas.py +45 -0
- foo_mcp/server.py +274 -0
- foo_mcp/smoke.py +84 -0
- foo_mcp/tracing.py +249 -0
- foo_mcp-0.1.0.dist-info/METADATA +526 -0
- foo_mcp-0.1.0.dist-info/RECORD +24 -0
- foo_mcp-0.1.0.dist-info/WHEEL +4 -0
- foo_mcp-0.1.0.dist-info/entry_points.txt +6 -0
- foo_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
foo_mcp/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""FOO MCP — multiagent orchestration as a Model Context Protocol server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("foo-mcp")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0+local"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "foo.config.v1",
|
|
3
|
+
"CONFIG": {
|
|
4
|
+
"instructions": "You are part of a transparent multiagent review system. Provide specific, evidence-oriented feedback and avoid unsupported claims.",
|
|
5
|
+
"name": "FOO MCP",
|
|
6
|
+
"user": "Researcher",
|
|
7
|
+
"output_dir": "runs",
|
|
8
|
+
"trace_include_raw_content": true
|
|
9
|
+
},
|
|
10
|
+
"MODELS": [
|
|
11
|
+
{
|
|
12
|
+
"agent_name": "Reviewer_A",
|
|
13
|
+
"provider": "mock",
|
|
14
|
+
"model_code": "mock-reviewer-a",
|
|
15
|
+
"model_name": "Deterministic Mock Reviewer A",
|
|
16
|
+
"temperature": 0.1,
|
|
17
|
+
"max_completion_tokens": 1000,
|
|
18
|
+
"harmonizer": false,
|
|
19
|
+
"agent_directive": "Focus on methodological flaws, traceability, and reproducibility."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"agent_name": "Reviewer_B",
|
|
23
|
+
"provider": "mock",
|
|
24
|
+
"model_code": "mock-reviewer-b",
|
|
25
|
+
"model_name": "Deterministic Mock Reviewer B",
|
|
26
|
+
"temperature": 0.3,
|
|
27
|
+
"max_completion_tokens": 1000,
|
|
28
|
+
"harmonizer": false,
|
|
29
|
+
"agent_directive": "Focus on assumptions, alternative interpretations, and missing evidence."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"agent_name": "Harmonizer",
|
|
33
|
+
"provider": "mock",
|
|
34
|
+
"model_code": "mock-harmonizer",
|
|
35
|
+
"model_name": "Deterministic Mock Harmonizer",
|
|
36
|
+
"temperature": 0.0,
|
|
37
|
+
"max_completion_tokens": 1500,
|
|
38
|
+
"harmonizer": true,
|
|
39
|
+
"harmonizer_directive": "Organize peer feedback for {source_agent_name} into Agreement, Disagreement, and Unique Observations. Preserve each observation without summarizing away detail.",
|
|
40
|
+
"agent_directive": "Preserve reviewer-specific detail and label all claims by source agent."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"agent_name": "HF_Disabled_Example",
|
|
44
|
+
"provider": "huggingface",
|
|
45
|
+
"model_code": "Qwen/Qwen2.5-7B-Instruct",
|
|
46
|
+
"model_name": "Hugging Face hosted inference example",
|
|
47
|
+
"temperature": 0.1,
|
|
48
|
+
"top_p": 0.95,
|
|
49
|
+
"max_completion_tokens": 1000,
|
|
50
|
+
"active": false,
|
|
51
|
+
"agent_directive": "Enable this agent after setting HF_TOKEN or logging in with Hugging Face tooling."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"agent_name": "MLX_Disabled_Example",
|
|
55
|
+
"provider": "mlx",
|
|
56
|
+
"model_code": "mlx-community/Llama-3.2-3B-Instruct-4bit",
|
|
57
|
+
"model_name": "Apple Silicon MLX local inference example",
|
|
58
|
+
"temperature": 0.0,
|
|
59
|
+
"max_completion_tokens": 512,
|
|
60
|
+
"max_kv_size": 4096,
|
|
61
|
+
"active": false,
|
|
62
|
+
"agent_directive": "Enable this agent on Apple Silicon after installing the mlx optional extra."
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
foo_mcp/config.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Typed configuration for the FOO MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
from foo_mcp.schemas import ProviderName
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG_PATH = Path("config/foo.agents.example.json")
|
|
16
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
17
|
+
_BUNDLED_CONFIG_PATH = _PACKAGE_DIR / "_data" / "foo.agents.example.json"
|
|
18
|
+
BUNDLED_CONFIG_PATH = _BUNDLED_CONFIG_PATH
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FooSettings(BaseModel):
|
|
22
|
+
"""Global settings from the legacy FOO CONFIG block plus MCP additions."""
|
|
23
|
+
|
|
24
|
+
instructions: str
|
|
25
|
+
name: str = "FOO MCP"
|
|
26
|
+
user: str = "Researcher"
|
|
27
|
+
model: str | None = None
|
|
28
|
+
cwd: str | None = Field(default=None, alias="CWD")
|
|
29
|
+
output_dir: str = "runs"
|
|
30
|
+
trace_include_raw_content: bool = True
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentSpec(BaseModel):
|
|
36
|
+
"""One configured agent."""
|
|
37
|
+
|
|
38
|
+
agent_name: str
|
|
39
|
+
model_code: str
|
|
40
|
+
provider: ProviderName | None = None
|
|
41
|
+
model_name: str | None = None
|
|
42
|
+
temperature: float | None = None
|
|
43
|
+
max_completion_tokens: int | None = None
|
|
44
|
+
top_p: float | None = None
|
|
45
|
+
top_k: int | None = None
|
|
46
|
+
min_p: float | None = None
|
|
47
|
+
max_kv_size: int | None = None
|
|
48
|
+
huggingface_provider: str | None = None
|
|
49
|
+
endpoint_url: str | None = None
|
|
50
|
+
endpoint_model: str | None = None
|
|
51
|
+
trust_remote_code: bool = False
|
|
52
|
+
eos_token: str | None = None
|
|
53
|
+
harmonizer: bool = False
|
|
54
|
+
harmonizer_directive: str = ""
|
|
55
|
+
agent_directive: str = ""
|
|
56
|
+
active: bool = True
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
59
|
+
|
|
60
|
+
@field_validator("harmonizer", mode="before")
|
|
61
|
+
@classmethod
|
|
62
|
+
def parse_boolish(cls, value: Any) -> bool:
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
return value
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
return value.strip().lower() in {"1", "true", "yes", "y"}
|
|
67
|
+
return bool(value)
|
|
68
|
+
|
|
69
|
+
@field_validator("provider", mode="before")
|
|
70
|
+
@classmethod
|
|
71
|
+
def normalize_provider(cls, value: Any) -> ProviderName | None:
|
|
72
|
+
if value is None or value == "":
|
|
73
|
+
return None
|
|
74
|
+
normalized = str(value).strip().lower()
|
|
75
|
+
aliases = {
|
|
76
|
+
"claude": "anthropic",
|
|
77
|
+
"anthropic": "anthropic",
|
|
78
|
+
"gemini": "google",
|
|
79
|
+
"google": "google",
|
|
80
|
+
"hf": "huggingface",
|
|
81
|
+
"huggingface": "huggingface",
|
|
82
|
+
"hugging-face": "huggingface",
|
|
83
|
+
"hugging_face": "huggingface",
|
|
84
|
+
"local_mlx": "mlx",
|
|
85
|
+
"mlx": "mlx",
|
|
86
|
+
"mlx-lm": "mlx",
|
|
87
|
+
"mlx_lm": "mlx",
|
|
88
|
+
"openai": "openai",
|
|
89
|
+
"mock": "mock",
|
|
90
|
+
}
|
|
91
|
+
if normalized not in aliases:
|
|
92
|
+
raise ValueError(f"Unsupported provider '{value}'")
|
|
93
|
+
return aliases[normalized] # type: ignore[return-value]
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def resolved_provider(self) -> ProviderName:
|
|
97
|
+
if self.provider:
|
|
98
|
+
return self.provider
|
|
99
|
+
model = self.model_code.lower()
|
|
100
|
+
if model.startswith("claude"):
|
|
101
|
+
return "anthropic"
|
|
102
|
+
if model.startswith("gemini"):
|
|
103
|
+
return "google"
|
|
104
|
+
if model.startswith("mlx-community/") or model.startswith("mlx_"):
|
|
105
|
+
return "mlx"
|
|
106
|
+
if model.startswith("hf_"):
|
|
107
|
+
return "huggingface"
|
|
108
|
+
if model.startswith("mock"):
|
|
109
|
+
return "mock"
|
|
110
|
+
return "openai"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FooConfig(BaseModel):
|
|
114
|
+
"""Root FOO config model."""
|
|
115
|
+
|
|
116
|
+
schema_version: str = "foo.config.v1"
|
|
117
|
+
settings: FooSettings = Field(alias="CONFIG")
|
|
118
|
+
models: list[AgentSpec] = Field(alias="MODELS")
|
|
119
|
+
|
|
120
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
121
|
+
|
|
122
|
+
@model_validator(mode="after")
|
|
123
|
+
def validate_agent_names(self) -> FooConfig:
|
|
124
|
+
names = [agent.agent_name for agent in self.models]
|
|
125
|
+
duplicates = sorted({name for name in names if names.count(name) > 1})
|
|
126
|
+
if duplicates:
|
|
127
|
+
raise ValueError(f"Duplicate agent names: {', '.join(duplicates)}")
|
|
128
|
+
if not self.models:
|
|
129
|
+
raise ValueError("At least one agent must be configured")
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def sanitized(self) -> dict[str, Any]:
|
|
133
|
+
data = self.model_dump(by_alias=True)
|
|
134
|
+
data["CONFIG"].pop("blockchain_salt", None)
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_dotenv_if_available() -> None:
|
|
139
|
+
try:
|
|
140
|
+
from dotenv import load_dotenv
|
|
141
|
+
except ImportError:
|
|
142
|
+
return
|
|
143
|
+
load_dotenv()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve_config_path(config_path: str | Path | None = None) -> Path:
|
|
147
|
+
if config_path:
|
|
148
|
+
return Path(config_path).expanduser()
|
|
149
|
+
env_path = os.getenv("FOO_MCP_CONFIG")
|
|
150
|
+
if env_path:
|
|
151
|
+
path = Path(env_path).expanduser()
|
|
152
|
+
if path.exists():
|
|
153
|
+
return path
|
|
154
|
+
print(
|
|
155
|
+
f"[foo-mcp] warning: FOO_MCP_CONFIG={env_path!r} does not exist; "
|
|
156
|
+
"falling back to bundled example.",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
# Fall back to the example shipped inside the package.
|
|
160
|
+
return _BUNDLED_CONFIG_PATH
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_config(config_path: str | Path | None = None) -> FooConfig:
|
|
164
|
+
load_dotenv_if_available()
|
|
165
|
+
path = resolve_config_path(config_path)
|
|
166
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
167
|
+
payload = json.load(handle)
|
|
168
|
+
return FooConfig.model_validate(payload)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clean_api_key(value: str | None) -> str | None:
|
|
172
|
+
"""Strip surrounding whitespace and matching wrapping quotes from a key.
|
|
173
|
+
|
|
174
|
+
.env loaders and shell exports occasionally leave keys quoted (KEY="sk-..."),
|
|
175
|
+
sometimes with nested layers (KEY="'sk-...'"). Repeatedly strip matched
|
|
176
|
+
outer quotes until none remain. Mismatched and interior quotes are kept.
|
|
177
|
+
"""
|
|
178
|
+
if value is None:
|
|
179
|
+
return None
|
|
180
|
+
cleaned = value.strip()
|
|
181
|
+
while len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in ('"', "'"):
|
|
182
|
+
cleaned = cleaned[1:-1].strip()
|
|
183
|
+
return cleaned or None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_clean_env(name: str) -> str | None:
|
|
187
|
+
return clean_api_key(os.getenv(name))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def env_key_for_provider(provider: ProviderName) -> tuple[str, ...]:
|
|
191
|
+
if provider == "openai":
|
|
192
|
+
return ("OPENAI_API_KEY",)
|
|
193
|
+
if provider == "anthropic":
|
|
194
|
+
return ("ANTHROPIC_API_KEY",)
|
|
195
|
+
if provider == "google":
|
|
196
|
+
return ("GEMINI_API_KEY", "GOOGLE_API_KEY")
|
|
197
|
+
if provider == "huggingface":
|
|
198
|
+
return ("HF_TOKEN", "HUGGINGFACE_API_KEY", "HUGGINGFACE_HUB_TOKEN")
|
|
199
|
+
return ()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def missing_env_keys_for_agent(agent: AgentSpec) -> list[str]:
|
|
203
|
+
if agent.resolved_provider == "huggingface" and agent.endpoint_url:
|
|
204
|
+
return []
|
|
205
|
+
if agent.resolved_provider == "mlx":
|
|
206
|
+
return []
|
|
207
|
+
candidates = env_key_for_provider(agent.resolved_provider)
|
|
208
|
+
if not candidates:
|
|
209
|
+
return []
|
|
210
|
+
if any(get_clean_env(name) for name in candidates):
|
|
211
|
+
return []
|
|
212
|
+
return list(candidates)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def is_bundled_example(path: str | Path) -> bool:
|
|
216
|
+
"""True if ``path`` resolves to the bundled in-package example config."""
|
|
217
|
+
try:
|
|
218
|
+
return Path(path).expanduser().resolve() == BUNDLED_CONFIG_PATH.resolve()
|
|
219
|
+
except OSError:
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def count_usable_real_agents(config: FooConfig) -> tuple[int, list[str], list[str]]:
|
|
224
|
+
"""Return (count, usable_names, blocked_reasons).
|
|
225
|
+
|
|
226
|
+
Usable = active, non-mock, with no missing provider env keys (or local mlx).
|
|
227
|
+
blocked_reasons names every active non-mock agent that isn't usable along with
|
|
228
|
+
the reason (missing key or inactive — though inactive is filtered earlier).
|
|
229
|
+
"""
|
|
230
|
+
usable: list[str] = []
|
|
231
|
+
blocked: list[str] = []
|
|
232
|
+
for agent in config.models:
|
|
233
|
+
if agent.resolved_provider == "mock":
|
|
234
|
+
continue
|
|
235
|
+
if not agent.active:
|
|
236
|
+
blocked.append(f"{agent.agent_name}: inactive")
|
|
237
|
+
continue
|
|
238
|
+
missing = missing_env_keys_for_agent(agent)
|
|
239
|
+
if missing:
|
|
240
|
+
blocked.append(f"{agent.agent_name}: missing {' or '.join(missing)}")
|
|
241
|
+
continue
|
|
242
|
+
usable.append(agent.agent_name)
|
|
243
|
+
return len(usable), usable, blocked
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def validate_runtime_agents(
|
|
247
|
+
config: FooConfig,
|
|
248
|
+
config_path: str | Path,
|
|
249
|
+
*,
|
|
250
|
+
allow_single_real: bool,
|
|
251
|
+
) -> tuple[bool, str | None]:
|
|
252
|
+
"""Check whether the active config has enough usable real agents to run.
|
|
253
|
+
|
|
254
|
+
Returns ``(ok, error_message)``. Skips enforcement when:
|
|
255
|
+
- ``allow_single_real`` is True (env-var escape hatch), OR
|
|
256
|
+
- ``config_path`` resolves to the bundled in-package example (so the
|
|
257
|
+
zero-key default flow continues to work).
|
|
258
|
+
Otherwise requires at least 2 usable non-mock agents.
|
|
259
|
+
"""
|
|
260
|
+
if allow_single_real:
|
|
261
|
+
return True, None
|
|
262
|
+
if is_bundled_example(config_path):
|
|
263
|
+
return True, None
|
|
264
|
+
count, usable, blocked = count_usable_real_agents(config)
|
|
265
|
+
if count >= 2:
|
|
266
|
+
return True, None
|
|
267
|
+
lines = [
|
|
268
|
+
f"foo-mcp requires at least 2 usable non-mock agents to start (found {count}).",
|
|
269
|
+
f" Usable: {', '.join(usable) if usable else '(none)'}",
|
|
270
|
+
f" Blocked: {', '.join(blocked) if blocked else '(none)'}",
|
|
271
|
+
"",
|
|
272
|
+
"Fix options:",
|
|
273
|
+
" 1. Run 'foo-mcp-init' to generate a working config interactively.",
|
|
274
|
+
" 2. Export the missing API keys for the providers listed above.",
|
|
275
|
+
" 3. For testing only, set FOO_MCP_ALLOW_SINGLE_PROVIDER=1 to bypass.",
|
|
276
|
+
]
|
|
277
|
+
return False, "\n".join(lines)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_system_prompt(settings: FooSettings, agent: AgentSpec) -> str:
|
|
281
|
+
preamble = (
|
|
282
|
+
f"Address the user as Dr. {settings.user}.\n\n"
|
|
283
|
+
f"Introduce yourself as {agent.agent_name}, AI assistant.\n\n"
|
|
284
|
+
)
|
|
285
|
+
directive = (
|
|
286
|
+
f"\n\nAgent specific instructions:\n{agent.agent_directive}\n"
|
|
287
|
+
if agent.agent_directive
|
|
288
|
+
else ""
|
|
289
|
+
)
|
|
290
|
+
return preamble + settings.instructions + directive
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""End-to-end consensus demo for foo-mcp.
|
|
2
|
+
|
|
3
|
+
Runs the multi-agent pipeline (broadcast -> vulnerability -> judgment -> reflection -> verify
|
|
4
|
+
-> export) against the configured agents and prints a structured report ending with the
|
|
5
|
+
harmonizer's consensus and the source agent's revised answer.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
foo-mcp-consensus-demo --question "What is the half-life of caffeine?"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from foo_mcp.config import (
|
|
22
|
+
load_config,
|
|
23
|
+
load_dotenv_if_available,
|
|
24
|
+
resolve_config_path,
|
|
25
|
+
validate_runtime_agents,
|
|
26
|
+
)
|
|
27
|
+
from foo_mcp.orchestrator import FooOrchestrator
|
|
28
|
+
from foo_mcp.tracing import TraceStore
|
|
29
|
+
|
|
30
|
+
DEFAULT_QUESTION = (
|
|
31
|
+
"What is the recommended daily caffeine intake for a healthy adult, "
|
|
32
|
+
"and what is the typical half-life in plasma?"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DemoArgs:
|
|
38
|
+
question: str | None
|
|
39
|
+
config_path: Path | None
|
|
40
|
+
destination: Path | None
|
|
41
|
+
max_chars: int
|
|
42
|
+
json_only: bool
|
|
43
|
+
allow_single_real: bool
|
|
44
|
+
source_agent: str | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_args(argv: list[str] | None = None) -> DemoArgs:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="foo-mcp-consensus-demo",
|
|
50
|
+
description=(
|
|
51
|
+
"Drive the full foo-mcp pipeline once and print a consensus report. "
|
|
52
|
+
"Useful as a smoke test that the configured providers can be reached."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--question", help="Question to ask the agents (default: caffeine prompt).")
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--config",
|
|
58
|
+
dest="config_path",
|
|
59
|
+
type=lambda s: Path(s).expanduser(),
|
|
60
|
+
help="Path to FooConfig JSON. Defaults to FOO_MCP_CONFIG / bundled example.",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--destination",
|
|
64
|
+
type=lambda s: Path(s).expanduser(),
|
|
65
|
+
help="Optional destination directory for the exported run bundle.",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--max-chars",
|
|
69
|
+
type=int,
|
|
70
|
+
default=280,
|
|
71
|
+
help="Snippet character limit per reviewer response (default: 280).",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--json-only",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Emit one machine-readable JSON blob and skip the human report.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--allow-single-real",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Bypass the >=2 real providers requirement.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--source-agent",
|
|
85
|
+
help="Override the auto-picked source agent (default: first active non-harmonizer).",
|
|
86
|
+
)
|
|
87
|
+
namespace = parser.parse_args(argv)
|
|
88
|
+
return DemoArgs(
|
|
89
|
+
question=namespace.question,
|
|
90
|
+
config_path=namespace.config_path,
|
|
91
|
+
destination=namespace.destination,
|
|
92
|
+
max_chars=max(80, namespace.max_chars),
|
|
93
|
+
json_only=namespace.json_only,
|
|
94
|
+
allow_single_real=namespace.allow_single_real,
|
|
95
|
+
source_agent=namespace.source_agent,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_question(args: DemoArgs) -> str:
|
|
100
|
+
if args.question:
|
|
101
|
+
return args.question
|
|
102
|
+
if not sys.stdin.isatty():
|
|
103
|
+
data = sys.stdin.read().strip()
|
|
104
|
+
if data:
|
|
105
|
+
return data
|
|
106
|
+
return DEFAULT_QUESTION
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def shorten(text: str | None, max_chars: int) -> str:
|
|
110
|
+
if not text:
|
|
111
|
+
return "(no content)"
|
|
112
|
+
collapsed = " ".join(text.split())
|
|
113
|
+
if len(collapsed) <= max_chars:
|
|
114
|
+
return collapsed
|
|
115
|
+
return collapsed[: max_chars - 1].rstrip() + "…"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def print_section(title: str) -> None:
|
|
119
|
+
print(f"\n=== {title} ===")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _fmt_latency(latency_ms: float | None) -> str:
|
|
123
|
+
if latency_ms is None:
|
|
124
|
+
return " ?ms"
|
|
125
|
+
return f"{int(latency_ms):>5d}ms"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def print_response_row(response: dict[str, Any], max_chars: int) -> None:
|
|
129
|
+
name = response.get("agent_name", "?")
|
|
130
|
+
provider = response.get("provider", "?")
|
|
131
|
+
model = response.get("model", "?")
|
|
132
|
+
latency = _fmt_latency(response.get("latency_ms"))
|
|
133
|
+
snippet = shorten(response.get("content"), max_chars)
|
|
134
|
+
error = response.get("error")
|
|
135
|
+
suffix = f" [ERROR: {error}]" if error else ""
|
|
136
|
+
print(f" {name:<22} | {provider}:{model} | {latency} | {snippet}{suffix}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def print_full_response(response: dict[str, Any], max_chars: int) -> None:
|
|
140
|
+
name = response.get("agent_name", "?")
|
|
141
|
+
provider = response.get("provider", "?")
|
|
142
|
+
model = response.get("model", "?")
|
|
143
|
+
latency = _fmt_latency(response.get("latency_ms"))
|
|
144
|
+
print(f" {name:<22} | {provider}:{model} | {latency}")
|
|
145
|
+
if response.get("error"):
|
|
146
|
+
print(f" ERROR: {response['error']}")
|
|
147
|
+
return
|
|
148
|
+
content = response.get("content") or "(no content)"
|
|
149
|
+
print()
|
|
150
|
+
if len(content) <= max_chars:
|
|
151
|
+
print(content)
|
|
152
|
+
else:
|
|
153
|
+
print(content[: max_chars - 1].rstrip() + "…")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def pick_source_agent(orchestrator: FooOrchestrator, override: str | None) -> str:
|
|
157
|
+
if override:
|
|
158
|
+
if override not in orchestrator.agents:
|
|
159
|
+
raise SystemExit(
|
|
160
|
+
f"--source-agent {override!r} is not in the config "
|
|
161
|
+
f"(known: {sorted(orchestrator.agents)})"
|
|
162
|
+
)
|
|
163
|
+
return override
|
|
164
|
+
for agent in orchestrator.agents.values():
|
|
165
|
+
if agent.spec.active and not agent.spec.harmonizer:
|
|
166
|
+
return agent.spec.agent_name
|
|
167
|
+
for agent in orchestrator.agents.values():
|
|
168
|
+
if agent.spec.active:
|
|
169
|
+
return agent.spec.agent_name
|
|
170
|
+
raise SystemExit("No active agents found in config")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_orchestrator(args: DemoArgs) -> tuple[FooOrchestrator, Path]:
|
|
174
|
+
config_path = resolve_config_path(args.config_path)
|
|
175
|
+
config = load_config(config_path)
|
|
176
|
+
ok, error = validate_runtime_agents(
|
|
177
|
+
config, config_path, allow_single_real=args.allow_single_real
|
|
178
|
+
)
|
|
179
|
+
if not ok:
|
|
180
|
+
print(error, file=sys.stderr)
|
|
181
|
+
raise SystemExit(2)
|
|
182
|
+
trace_store = TraceStore(config.settings.output_dir, config_snapshot=config.sanitized())
|
|
183
|
+
return FooOrchestrator(config=config, trace_store=trace_store), config_path
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_pipeline(orchestrator: FooOrchestrator, question: str, args: DemoArgs) -> int:
|
|
187
|
+
blob: dict[str, Any] = {}
|
|
188
|
+
|
|
189
|
+
print_section("FOO MCP Consensus Demo") if not args.json_only else None
|
|
190
|
+
if not args.json_only:
|
|
191
|
+
print(f"Run ID: {orchestrator.run_id}")
|
|
192
|
+
print(f"Question: {question}")
|
|
193
|
+
|
|
194
|
+
broadcast = orchestrator.broadcast_message(question)
|
|
195
|
+
blob["broadcast"] = broadcast
|
|
196
|
+
if not args.json_only:
|
|
197
|
+
print_section("1. Broadcast")
|
|
198
|
+
for response in broadcast["data"]["responses"]:
|
|
199
|
+
print_response_row(response, args.max_chars)
|
|
200
|
+
if not broadcast["ok"]:
|
|
201
|
+
if args.json_only:
|
|
202
|
+
print(json.dumps(blob, indent=2))
|
|
203
|
+
return 10
|
|
204
|
+
|
|
205
|
+
source = pick_source_agent(orchestrator, args.source_agent)
|
|
206
|
+
blob["source_agent"] = source
|
|
207
|
+
|
|
208
|
+
vulnerability = orchestrator.run_vulnerability_analysis(source_agent_name=source)
|
|
209
|
+
blob["vulnerability"] = vulnerability
|
|
210
|
+
if not args.json_only:
|
|
211
|
+
print_section(f"2. Vulnerability Analysis (source = {source})")
|
|
212
|
+
critiques = vulnerability["data"].get("critiques", [])
|
|
213
|
+
if not critiques:
|
|
214
|
+
print(" (no peer critiques produced — only one non-harmonizer agent active?)")
|
|
215
|
+
for critique in critiques:
|
|
216
|
+
print_response_row(critique, args.max_chars)
|
|
217
|
+
if not vulnerability["ok"]:
|
|
218
|
+
if args.json_only:
|
|
219
|
+
print(json.dumps(blob, indent=2))
|
|
220
|
+
return 11
|
|
221
|
+
|
|
222
|
+
judgment = orchestrator.run_judgment_analysis(source_agent_name=source)
|
|
223
|
+
blob["judgment"] = judgment
|
|
224
|
+
if not args.json_only:
|
|
225
|
+
print_section(f"3. Harmonizer Judgment (consensus on {source})")
|
|
226
|
+
for j in judgment["data"].get("judgments", []):
|
|
227
|
+
print_full_response(j, args.max_chars * 4)
|
|
228
|
+
if not judgment["ok"]:
|
|
229
|
+
if args.json_only:
|
|
230
|
+
print(json.dumps(blob, indent=2))
|
|
231
|
+
return 12
|
|
232
|
+
|
|
233
|
+
reflection = orchestrator.run_reflection_analysis(target_agent_name=source)
|
|
234
|
+
blob["reflection"] = reflection
|
|
235
|
+
if not args.json_only:
|
|
236
|
+
print_section(f"4. Reflection (revised by {source})")
|
|
237
|
+
response = reflection["data"].get("response")
|
|
238
|
+
if response:
|
|
239
|
+
print_full_response(response, args.max_chars * 4)
|
|
240
|
+
if not reflection["ok"]:
|
|
241
|
+
if args.json_only:
|
|
242
|
+
print(json.dumps(blob, indent=2))
|
|
243
|
+
return 13
|
|
244
|
+
|
|
245
|
+
verify = orchestrator.verify_trace_chain()
|
|
246
|
+
blob["verify"] = verify
|
|
247
|
+
if not args.json_only:
|
|
248
|
+
print_section("5. Trace Chain Verification")
|
|
249
|
+
report = verify["data"]["verification"]
|
|
250
|
+
print(
|
|
251
|
+
f" ok={report['ok']} event_count={report['event_count']} "
|
|
252
|
+
f"errors={len(report.get('errors', []))}"
|
|
253
|
+
)
|
|
254
|
+
if report.get("errors"):
|
|
255
|
+
for err in report["errors"][:5]:
|
|
256
|
+
print(f" {err}")
|
|
257
|
+
if not verify["data"]["verification"]["ok"]:
|
|
258
|
+
if args.json_only:
|
|
259
|
+
print(json.dumps(blob, indent=2))
|
|
260
|
+
return 14
|
|
261
|
+
|
|
262
|
+
bundle = orchestrator.export_run_bundle(str(args.destination) if args.destination else None)
|
|
263
|
+
blob["bundle"] = bundle
|
|
264
|
+
if not args.json_only:
|
|
265
|
+
print_section("6. Bundle Export")
|
|
266
|
+
bdata = bundle["data"]
|
|
267
|
+
print(f" run_id={bdata.get('run_id')}")
|
|
268
|
+
print(f" bundle_path={bdata.get('bundle_path')}")
|
|
269
|
+
if not bundle["ok"]:
|
|
270
|
+
if args.json_only:
|
|
271
|
+
print(json.dumps(blob, indent=2))
|
|
272
|
+
return 15
|
|
273
|
+
|
|
274
|
+
if args.json_only:
|
|
275
|
+
print(json.dumps(blob, indent=2))
|
|
276
|
+
else:
|
|
277
|
+
print_section("Done")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main(argv: list[str] | None = None) -> None:
|
|
282
|
+
load_dotenv_if_available()
|
|
283
|
+
args = parse_args(argv)
|
|
284
|
+
question = read_question(args)
|
|
285
|
+
orchestrator, _ = build_orchestrator(args)
|
|
286
|
+
rc = run_pipeline(orchestrator, question, args)
|
|
287
|
+
sys.exit(rc)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
main()
|