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 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()