symposium-protocol 1.7.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.
- symposium/__init__.py +73 -0
- symposium/cli/__init__.py +5 -0
- symposium/cli/main.py +414 -0
- symposium/integrations/__init__.py +12 -0
- symposium/integrations/mcp_server.py +774 -0
- symposium/models.py +678 -0
- symposium/observability/__init__.py +15 -0
- symposium/observability/metrics.py +498 -0
- symposium/personas/__init__.py +29 -0
- symposium/personas/defaults.py +127 -0
- symposium/providers/__init__.py +47 -0
- symposium/providers/_http_common.py +231 -0
- symposium/providers/anthropic.py +866 -0
- symposium/providers/base.py +40 -0
- symposium/providers/claude_cli.py +379 -0
- symposium/providers/fake.py +133 -0
- symposium/providers/openai.py +753 -0
- symposium/providers/registry.py +155 -0
- symposium/replay/__init__.py +38 -0
- symposium/replay/execution.py +528 -0
- symposium/replay/transcript.py +59 -0
- symposium/scheduler/__init__.py +13 -0
- symposium/scheduler/loop.py +770 -0
- symposium/selector/__init__.py +30 -0
- symposium/selector/base.py +132 -0
- symposium/selector/fixed.py +23 -0
- symposium/selector/llm.py +239 -0
- symposium/selector/rules.py +170 -0
- symposium/storage/__init__.py +12 -0
- symposium/storage/digest.py +59 -0
- symposium/storage/paths.py +71 -0
- symposium/storage/writer.py +161 -0
- symposium_protocol-1.7.0.dist-info/METADATA +699 -0
- symposium_protocol-1.7.0.dist-info/RECORD +37 -0
- symposium_protocol-1.7.0.dist-info/WHEEL +4 -0
- symposium_protocol-1.7.0.dist-info/entry_points.txt +3 -0
- symposium_protocol-1.7.0.dist-info/licenses/LICENSE +201 -0
symposium/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Symposium — reference implementation of the Symposium 1.0 protocol.
|
|
2
|
+
|
|
3
|
+
The protocol is specified in `docs/specification.md` of this repository.
|
|
4
|
+
This package exposes a runtime conformant with that specification's
|
|
5
|
+
[Core MVP] surface (§1–§9) using a deterministic `FakeProvider` adapter
|
|
6
|
+
for testing and demonstration.
|
|
7
|
+
|
|
8
|
+
Public surface:
|
|
9
|
+
|
|
10
|
+
from symposium import Session, Config, run_session
|
|
11
|
+
from symposium.providers import FakeProvider, ProviderAdapter
|
|
12
|
+
|
|
13
|
+
The CLI entry point is `symposium`; see `symposium --help`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from symposium.models import (
|
|
17
|
+
Artifact,
|
|
18
|
+
Config,
|
|
19
|
+
ContextPacket,
|
|
20
|
+
DirectRequest,
|
|
21
|
+
FakeProviderScript,
|
|
22
|
+
Message,
|
|
23
|
+
Persona,
|
|
24
|
+
ProviderRequest,
|
|
25
|
+
ProviderResult,
|
|
26
|
+
RunManifest,
|
|
27
|
+
SelectorOutput,
|
|
28
|
+
SynthesisContent,
|
|
29
|
+
TerminationArtifact,
|
|
30
|
+
TurnStructuredOutput,
|
|
31
|
+
Verdict,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Artifact",
|
|
36
|
+
"Config",
|
|
37
|
+
"ContextPacket",
|
|
38
|
+
"DirectRequest",
|
|
39
|
+
"FakeProviderScript",
|
|
40
|
+
"Message",
|
|
41
|
+
"Persona",
|
|
42
|
+
"ProviderRequest",
|
|
43
|
+
"ProviderResult",
|
|
44
|
+
"RunManifest",
|
|
45
|
+
"SelectorOutput",
|
|
46
|
+
"SynthesisContent",
|
|
47
|
+
"TerminationArtifact",
|
|
48
|
+
"TurnStructuredOutput",
|
|
49
|
+
"Verdict",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# Package / runtime release version. Distinct from SCHEMA_VERSION (the
|
|
53
|
+
# frozen v1.0.0 protocol + JSON Schemas, which MUST NOT move here) and from
|
|
54
|
+
# storage.writer.PRODUCER_VERSION (the §7.6-condition-#1 reproduction-surface
|
|
55
|
+
# identity). The MVP build-out (M1–M6: orchestrator loop, Fake/OpenAI/
|
|
56
|
+
# Anthropic adapters, §7.9 metrics, §7.5/§7.6 replay, fixed/rules/llm
|
|
57
|
+
# selector) ships as 1.5.0.
|
|
58
|
+
__version__ = "1.5.0"
|
|
59
|
+
SCHEMA_VERSION = "1.0.0"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __getattr__(name: str):
|
|
63
|
+
# Lazy import of scheduler-side symbols so models can be imported
|
|
64
|
+
# before the scheduler module is built.
|
|
65
|
+
if name in ("Session", "run_session"):
|
|
66
|
+
from symposium.scheduler import Session, run_session # type: ignore
|
|
67
|
+
|
|
68
|
+
return {"Session": Session, "run_session": run_session}[name]
|
|
69
|
+
if name == "run_selector":
|
|
70
|
+
from symposium.selector import run_selector # type: ignore
|
|
71
|
+
|
|
72
|
+
return run_selector
|
|
73
|
+
raise AttributeError(f"module 'symposium' has no attribute {name!r}")
|
symposium/cli/main.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""`symposium` CLI (§11.2).
|
|
2
|
+
|
|
3
|
+
MVP subcommands:
|
|
4
|
+
|
|
5
|
+
symposium run --config CONFIG.yaml [--script SCRIPT.json]
|
|
6
|
+
[--selector-script SCRIPT.json] [--output runs/] [problem.md]
|
|
7
|
+
Runs a session. The runtime resolves `provider` strings on every
|
|
8
|
+
agent (and on the coordinator) through the adapter registry
|
|
9
|
+
(§6.11). The built-in registry ships `openai`; the `fake` adapter
|
|
10
|
+
is registered ad-hoc when `--script` is given. The §4.1 selector
|
|
11
|
+
runs first: `fixed` / `rules` make no provider call; `llm` makes one
|
|
12
|
+
bounded call driven by `--selector-script` (a separate FakeProvider).
|
|
13
|
+
Every run writes `<run_dir>/selector_output.json` (§5.11).
|
|
14
|
+
|
|
15
|
+
symposium replay RUN_DIR
|
|
16
|
+
Re-renders the stored canonical_transcript and verifies the
|
|
17
|
+
transcript_digest.
|
|
18
|
+
|
|
19
|
+
symposium validate ARTIFACT.json
|
|
20
|
+
Validates an artifact against the v1.0.0 JSON Schemas.
|
|
21
|
+
|
|
22
|
+
symposium metrics RUN_DIR
|
|
23
|
+
Computes the §7.9 MVP observability metric set offline from
|
|
24
|
+
`<RUN_DIR>/artifact.json` and writes `<RUN_DIR>/metrics.json`.
|
|
25
|
+
|
|
26
|
+
symposium execution-replay RUN_DIR [--script SCRIPT.json] [--output runs/]
|
|
27
|
+
Re-runs the orchestrator against the original problem_statement /
|
|
28
|
+
Config under the §7.6 ten pinning conditions (distinct from
|
|
29
|
+
`replay`, which is the §7.5 unconditional transcript re-render). On
|
|
30
|
+
an unsatisfiable pinning condition it aborts with exit code 3; on a
|
|
31
|
+
digest mismatch after a successful replay, exit code 4.
|
|
32
|
+
|
|
33
|
+
Environment variables consumed by built-in adapters:
|
|
34
|
+
|
|
35
|
+
OPENAI_API_KEY — required when any agent declares `provider: openai`.
|
|
36
|
+
The OpenAIProvider fails fast at construction
|
|
37
|
+
(§6.8) if the variable is missing.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
import click
|
|
48
|
+
import yaml
|
|
49
|
+
|
|
50
|
+
from symposium.models import Artifact, Config, FakeProviderScript
|
|
51
|
+
from symposium.observability import (
|
|
52
|
+
MetricsConsistencyError,
|
|
53
|
+
compute_metrics,
|
|
54
|
+
write_metrics,
|
|
55
|
+
)
|
|
56
|
+
from symposium.providers import (
|
|
57
|
+
FakeProvider,
|
|
58
|
+
MissingCredentialsError,
|
|
59
|
+
UnknownProviderError,
|
|
60
|
+
default_registry,
|
|
61
|
+
make_fake_factory,
|
|
62
|
+
)
|
|
63
|
+
from symposium.replay import PinningViolation, execution_replay, replay_transcript
|
|
64
|
+
from symposium.scheduler import run_session
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
68
|
+
@click.version_option(package_name="symposium-protocol")
|
|
69
|
+
def main() -> None:
|
|
70
|
+
"""Reference runtime for the Symposium 1.0 protocol."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@main.command("run")
|
|
74
|
+
@click.option(
|
|
75
|
+
"--config", "config_path",
|
|
76
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
77
|
+
required=True,
|
|
78
|
+
help="Session config (YAML or JSON).",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--script", "script_path",
|
|
82
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
83
|
+
required=False,
|
|
84
|
+
help="FakeProvider script (JSON). Required when any agent declares `provider: fake`.",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--selector-script", "selector_script_path",
|
|
88
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
89
|
+
required=False,
|
|
90
|
+
help=(
|
|
91
|
+
"FakeProvider script (JSON) driving the §4.1 `llm` selector invocation "
|
|
92
|
+
"(mirrors --script). Used only when selector.strategy = llm; ignored "
|
|
93
|
+
"for fixed / rules."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
@click.option(
|
|
97
|
+
"--output", "runs_root",
|
|
98
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
99
|
+
default=Path("runs"),
|
|
100
|
+
show_default=True,
|
|
101
|
+
help="Root directory for persisted run.",
|
|
102
|
+
)
|
|
103
|
+
@click.argument(
|
|
104
|
+
"problem_path",
|
|
105
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
106
|
+
required=False,
|
|
107
|
+
)
|
|
108
|
+
def run_cmd(
|
|
109
|
+
config_path: Path,
|
|
110
|
+
script_path: Optional[Path],
|
|
111
|
+
selector_script_path: Optional[Path],
|
|
112
|
+
runs_root: Path,
|
|
113
|
+
problem_path: Optional[Path],
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Run a Symposium session against the registered providers."""
|
|
116
|
+
config = _load_config(config_path, problem_path)
|
|
117
|
+
|
|
118
|
+
registry = default_registry()
|
|
119
|
+
if script_path is not None:
|
|
120
|
+
fp = FakeProvider(script=_load_script(script_path))
|
|
121
|
+
registry.register("fake", make_fake_factory(fp))
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
providers = registry.build_session_providers(config)
|
|
125
|
+
except UnknownProviderError as exc:
|
|
126
|
+
click.echo(
|
|
127
|
+
f"ERROR: {exc} — either register a factory or set provider correctly in the config.",
|
|
128
|
+
err=True,
|
|
129
|
+
)
|
|
130
|
+
sys.exit(2)
|
|
131
|
+
except MissingCredentialsError as exc:
|
|
132
|
+
click.echo(f"ERROR: {exc}", err=True)
|
|
133
|
+
sys.exit(2)
|
|
134
|
+
|
|
135
|
+
# §4.1 `llm` selector: a distinct FakeProvider drives the single selector
|
|
136
|
+
# invocation so it never consumes deliberation-script entries.
|
|
137
|
+
selector_providers = None
|
|
138
|
+
if config.selector.strategy == "llm" and selector_script_path is not None:
|
|
139
|
+
sel_fp = FakeProvider(script=_load_script(selector_script_path))
|
|
140
|
+
selector_providers = {"default": sel_fp}
|
|
141
|
+
|
|
142
|
+
artifact = run_session(
|
|
143
|
+
config,
|
|
144
|
+
providers,
|
|
145
|
+
runs_root=str(runs_root),
|
|
146
|
+
selector_providers=selector_providers,
|
|
147
|
+
)
|
|
148
|
+
run_dir = runs_root / config.session_id
|
|
149
|
+
click.echo(f"session_id={config.session_id}")
|
|
150
|
+
click.echo(f"selector_strategy={config.selector.strategy}")
|
|
151
|
+
sel_path = run_dir / "selector_output.json"
|
|
152
|
+
if sel_path.exists():
|
|
153
|
+
selection = json.loads(sel_path.read_text())
|
|
154
|
+
click.echo(f"selected_agents={selection['selected_agents']}")
|
|
155
|
+
click.echo(f"outcome.kind={artifact.outcome.kind}")
|
|
156
|
+
click.echo(f"transcript_digest={artifact.transcript_digest}")
|
|
157
|
+
click.echo(f"persisted_to={run_dir}/")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@main.command("replay")
|
|
161
|
+
@click.argument("run_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
162
|
+
def replay_cmd(run_dir: Path) -> None:
|
|
163
|
+
"""Re-render a stored canonical_transcript and verify its digest (§7.5)."""
|
|
164
|
+
result = replay_transcript(run_dir)
|
|
165
|
+
status = "match" if result.digest_matches else "MISMATCH"
|
|
166
|
+
click.echo(f"messages={len(result.re_emitted_messages)}")
|
|
167
|
+
click.echo(f"stored_digest={result.artifact.transcript_digest}")
|
|
168
|
+
click.echo(f"recomputed_digest={result.recomputed_digest}")
|
|
169
|
+
click.echo(f"digest={status}")
|
|
170
|
+
if not result.digest_matches:
|
|
171
|
+
sys.exit(2)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@main.command("validate")
|
|
175
|
+
@click.argument("artifact_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
176
|
+
def validate_cmd(artifact_path: Path) -> None:
|
|
177
|
+
"""Validate an artifact.json against the v1.0.0 Artifact schema."""
|
|
178
|
+
from jsonschema import Draft202012Validator
|
|
179
|
+
schemas_dir = (
|
|
180
|
+
Path(__file__).resolve().parents[2] / "docs" / "schemas" / "v1.0.0"
|
|
181
|
+
)
|
|
182
|
+
registry = _load_registry(schemas_dir)
|
|
183
|
+
artifact_schema = json.loads((schemas_dir / "artifact.schema.json").read_text())
|
|
184
|
+
validator = Draft202012Validator(artifact_schema, registry=registry)
|
|
185
|
+
data = json.loads(artifact_path.read_text())
|
|
186
|
+
errors = list(validator.iter_errors(data))
|
|
187
|
+
if errors:
|
|
188
|
+
for err in errors:
|
|
189
|
+
click.echo(f"ERROR: {err.message} at {list(err.absolute_path)}", err=True)
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
click.echo("VALID")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@main.command("metrics")
|
|
195
|
+
@click.argument("run_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
196
|
+
@click.option(
|
|
197
|
+
"--output", "output_path",
|
|
198
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
199
|
+
default=None,
|
|
200
|
+
help="Write metrics.json here instead of <run_dir>/metrics.json.",
|
|
201
|
+
)
|
|
202
|
+
@click.option(
|
|
203
|
+
"--quiet", is_flag=True,
|
|
204
|
+
help="Suppress the human-readable summary on stdout.",
|
|
205
|
+
)
|
|
206
|
+
def metrics_cmd(run_dir: Path, output_path: Optional[Path], quiet: bool) -> None:
|
|
207
|
+
"""Compute §7.9 MVP observability metrics from a persisted run directory."""
|
|
208
|
+
artifact_path = run_dir / "artifact.json"
|
|
209
|
+
if not artifact_path.exists():
|
|
210
|
+
click.echo(f"ERROR: {artifact_path} not found", err=True)
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
try:
|
|
213
|
+
raw = json.loads(artifact_path.read_text())
|
|
214
|
+
artifact = Artifact.model_validate(raw)
|
|
215
|
+
except Exception as exc: # noqa: BLE001 — surface any parse/validation failure as exit 1
|
|
216
|
+
click.echo(f"ERROR: failed to load artifact: {exc}", err=True)
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
metrics = compute_metrics(artifact)
|
|
221
|
+
except MetricsConsistencyError as exc:
|
|
222
|
+
click.echo(f"ERROR: metrics-consistency invariant failed: {exc}", err=True)
|
|
223
|
+
sys.exit(2)
|
|
224
|
+
|
|
225
|
+
if output_path is None:
|
|
226
|
+
out_path = write_metrics(run_dir, metrics)
|
|
227
|
+
else:
|
|
228
|
+
# Inline mirror of write_metrics with a caller-chosen destination.
|
|
229
|
+
from symposium.observability.metrics import _atomic_write_text
|
|
230
|
+
|
|
231
|
+
payload = metrics.model_dump(mode="json", exclude_none=False)
|
|
232
|
+
text = json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
233
|
+
_atomic_write_text(output_path, text)
|
|
234
|
+
out_path = output_path
|
|
235
|
+
|
|
236
|
+
if not quiet:
|
|
237
|
+
_print_metrics_summary(metrics, out_path)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _print_metrics_summary(metrics, out_path: Path) -> None:
|
|
241
|
+
click.echo(f"session_id={metrics.session_id}")
|
|
242
|
+
click.echo(f"transcript_digest={metrics.transcript_digest}")
|
|
243
|
+
if metrics.outcome_kind == "termination":
|
|
244
|
+
click.echo(f"outcome=termination ({metrics.termination_reason})")
|
|
245
|
+
else:
|
|
246
|
+
click.echo("outcome=synthesis")
|
|
247
|
+
tc = metrics.tokens_cumulative
|
|
248
|
+
click.echo(
|
|
249
|
+
f"tokens={tc.total_tokens} "
|
|
250
|
+
f"(prompt={tc.prompt_tokens}, completion={tc.completion_tokens})"
|
|
251
|
+
)
|
|
252
|
+
click.echo(f"cost_usd={metrics.cost_cumulative.cost_usd}")
|
|
253
|
+
|
|
254
|
+
top3 = sorted(
|
|
255
|
+
metrics.tokens_per_agent.items(),
|
|
256
|
+
key=lambda kv: kv[1].total_tokens,
|
|
257
|
+
reverse=True,
|
|
258
|
+
)[:3]
|
|
259
|
+
if top3:
|
|
260
|
+
click.echo("top_agents_by_tokens:")
|
|
261
|
+
for agent, tb in top3:
|
|
262
|
+
click.echo(f" {agent}: {tb.total_tokens}")
|
|
263
|
+
|
|
264
|
+
click.echo(f"branch_depth_max={metrics.branch_depth_max}")
|
|
265
|
+
click.echo(f"deferred_queue_length_max={metrics.deferred_queue_length_max}")
|
|
266
|
+
click.echo(
|
|
267
|
+
f"panel_contraction_total={sum(p.count for p in metrics.panel_contraction_count)}"
|
|
268
|
+
)
|
|
269
|
+
click.echo(f"usage_estimated={metrics.usage_estimated}")
|
|
270
|
+
click.echo(f"persisted_to={out_path}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@main.command("execution-replay")
|
|
274
|
+
@click.argument("run_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
275
|
+
@click.option(
|
|
276
|
+
"--script", "script_path",
|
|
277
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
278
|
+
required=False,
|
|
279
|
+
help="FakeProvider script (JSON). Required when the persisted config uses `provider: fake`.",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"--output", "fresh_runs_root",
|
|
283
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
284
|
+
default=None,
|
|
285
|
+
help="Root for the fresh `<session_id>-replay/` run dir (default: the run's parent).",
|
|
286
|
+
)
|
|
287
|
+
@click.option("--quiet", is_flag=True, help="Suppress the human-readable summary on stdout.")
|
|
288
|
+
@click.option(
|
|
289
|
+
"--assume-cache-cleared", is_flag=True,
|
|
290
|
+
help="Assert the §7.6 condition #6 prompt-cache assumption for live providers.",
|
|
291
|
+
)
|
|
292
|
+
def execution_replay_cmd(
|
|
293
|
+
run_dir: Path,
|
|
294
|
+
script_path: Optional[Path],
|
|
295
|
+
fresh_runs_root: Optional[Path],
|
|
296
|
+
quiet: bool,
|
|
297
|
+
assume_cache_cleared: bool,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Re-run a persisted session under the §7.6 pinning conditions.
|
|
300
|
+
|
|
301
|
+
The library API also accepts `fixed_clock` and `persona_hashes`; both
|
|
302
|
+
are caller-side knobs not exposed on the command line in M5. With no
|
|
303
|
+
`fixed_clock`, message ids and timestamps are replayed from the
|
|
304
|
+
recorded transcript (§7.6 #8 fixed clock source), so a deterministic
|
|
305
|
+
FakeProvider run reproduces its `transcript_digest`. Exit codes: 0
|
|
306
|
+
match, 3 pinning violation, 4 digest mismatch, 1 any other error.
|
|
307
|
+
"""
|
|
308
|
+
config_path = run_dir / "config.json"
|
|
309
|
+
if not config_path.exists():
|
|
310
|
+
click.echo(f"ERROR: {config_path} not found", err=True)
|
|
311
|
+
sys.exit(1)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
config = Config.model_validate(json.loads(config_path.read_text()))
|
|
315
|
+
except Exception as exc: # noqa: BLE001 — surface parse/validation failure as exit 1
|
|
316
|
+
click.echo(f"ERROR: failed to load config: {exc}", err=True)
|
|
317
|
+
sys.exit(1)
|
|
318
|
+
|
|
319
|
+
registry = default_registry()
|
|
320
|
+
if script_path is not None:
|
|
321
|
+
fp = FakeProvider(script=_load_script(script_path))
|
|
322
|
+
registry.register("fake", make_fake_factory(fp))
|
|
323
|
+
try:
|
|
324
|
+
providers = registry.build_session_providers(config)
|
|
325
|
+
except (UnknownProviderError, MissingCredentialsError) as exc:
|
|
326
|
+
click.echo(
|
|
327
|
+
f"ERROR: {exc} — pass --script for a fake session, or register/credential the adapter.",
|
|
328
|
+
err=True,
|
|
329
|
+
)
|
|
330
|
+
sys.exit(1)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
result = execution_replay(
|
|
334
|
+
run_dir,
|
|
335
|
+
providers=providers,
|
|
336
|
+
fresh_runs_root=fresh_runs_root,
|
|
337
|
+
assume_cache_cleared=assume_cache_cleared,
|
|
338
|
+
)
|
|
339
|
+
except PinningViolation as exc:
|
|
340
|
+
click.echo(f"ERROR: pinning_violation [{exc.condition}]: {exc}", err=True)
|
|
341
|
+
sys.exit(3)
|
|
342
|
+
except Exception as exc: # noqa: BLE001 — missing/invalid persisted state → exit 1
|
|
343
|
+
click.echo(f"ERROR: execution_replay failed: {exc}", err=True)
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
|
|
346
|
+
if not quiet:
|
|
347
|
+
for w in result.warnings:
|
|
348
|
+
click.echo(f"WARNING: {w}", err=True)
|
|
349
|
+
click.echo(f"conditions_checked={','.join(result.conditions_checked)}")
|
|
350
|
+
click.echo(f"conditions_assumed={','.join(result.conditions_assumed)}")
|
|
351
|
+
click.echo(f"original_digest={result.original_digest}")
|
|
352
|
+
click.echo(f"fresh_digest={result.fresh_digest}")
|
|
353
|
+
click.echo(f"fresh_run_dir={result.fresh_run_dir}/")
|
|
354
|
+
|
|
355
|
+
if not result.digest_matches:
|
|
356
|
+
diverge = result.first_diverging_message_id or "stored digest differs from recomputed (no message-level divergence)"
|
|
357
|
+
click.echo(f"digest=MISMATCH (first_divergence={diverge})")
|
|
358
|
+
sys.exit(4)
|
|
359
|
+
click.echo("digest=match")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# helpers
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _load_config(path: Path, problem_path: Optional[Path]) -> Config:
|
|
368
|
+
raw = _read_yaml_or_json(path)
|
|
369
|
+
if problem_path is not None:
|
|
370
|
+
raw["problem_statement"] = problem_path.read_text().strip()
|
|
371
|
+
from symposium.personas import persona_by_id
|
|
372
|
+
|
|
373
|
+
def _resolve(ac: dict) -> dict:
|
|
374
|
+
ref = ac.get("persona_ref")
|
|
375
|
+
if isinstance(ref, str):
|
|
376
|
+
try:
|
|
377
|
+
ac["persona_ref"] = persona_by_id(ref).model_dump(exclude_none=True)
|
|
378
|
+
except KeyError:
|
|
379
|
+
pass
|
|
380
|
+
return ac
|
|
381
|
+
|
|
382
|
+
for ac in raw.get("agents", []):
|
|
383
|
+
_resolve(ac)
|
|
384
|
+
if isinstance(raw.get("coordinator"), dict):
|
|
385
|
+
_resolve(raw["coordinator"])
|
|
386
|
+
return Config.model_validate(raw)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _load_script(path: Path) -> FakeProviderScript:
|
|
390
|
+
return FakeProviderScript.model_validate(_read_yaml_or_json(path))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _read_yaml_or_json(path: Path):
|
|
394
|
+
text = path.read_text()
|
|
395
|
+
if path.suffix.lower() in (".yaml", ".yml"):
|
|
396
|
+
return yaml.safe_load(text)
|
|
397
|
+
return json.loads(text)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _load_registry(schemas_dir: Path):
|
|
401
|
+
from referencing import Registry, Resource
|
|
402
|
+
|
|
403
|
+
resources = []
|
|
404
|
+
for schema_path in schemas_dir.glob("*.json"):
|
|
405
|
+
data = json.loads(schema_path.read_text())
|
|
406
|
+
if "$id" not in data:
|
|
407
|
+
continue
|
|
408
|
+
resources.append((data["$id"], Resource.from_contents(data)))
|
|
409
|
+
resources.append((schema_path.name, Resource.from_contents(data)))
|
|
410
|
+
return Registry().with_resources(resources)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Host-integration layer (spec §11.4 / §11.5).
|
|
2
|
+
|
|
3
|
+
These modules are *consumers* of the Symposium public API — they import
|
|
4
|
+
and call the frozen runtime (`run_session`, the §5.x models, the
|
|
5
|
+
selector / replay / metrics surface) without changing it. Nothing here
|
|
6
|
+
is part of the protocol or the schemas.
|
|
7
|
+
|
|
8
|
+
The MCP server (`symposium.integrations.mcp_server`) depends on the
|
|
9
|
+
optional `mcp` SDK and is therefore NOT imported here: importing
|
|
10
|
+
`symposium` (or running the CLI) must keep working when the `[mcp]`
|
|
11
|
+
extra is not installed. Import `mcp_server` explicitly to use it.
|
|
12
|
+
"""
|