agentversion 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.
@@ -0,0 +1,31 @@
1
+ """AgentVersion — reference implementation.
2
+
3
+ An open specification for versioning agent runtimes and keeping datasets valid.
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError
7
+ from importlib.metadata import version as _pkg_version
8
+
9
+ try:
10
+ __version__ = _pkg_version("agentversion")
11
+ except PackageNotFoundError: # editable install before metadata is registered
12
+ __version__ = "0.0.0+unknown"
13
+
14
+ from agentversion.constants import SPEC_VERSION
15
+ from agentversion.hasher import hash_manifest, hash_surface
16
+ from agentversion.manifest import AgentManifest
17
+ from agentversion.validator import (
18
+ ValidationResult,
19
+ validate_manifest,
20
+ validate_manifest_file,
21
+ )
22
+
23
+ __all__ = [
24
+ "SPEC_VERSION",
25
+ "AgentManifest",
26
+ "ValidationResult",
27
+ "hash_manifest",
28
+ "hash_surface",
29
+ "validate_manifest",
30
+ "validate_manifest_file",
31
+ ]
@@ -0,0 +1,23 @@
1
+ """Shared types used across replay, dataset, and other spec modules.
2
+
3
+ Internal module — do not import from outside the package. Public re-exports
4
+ live in ``agentversion.replay`` and ``agentversion.dataset``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ from pydantic import BaseModel
12
+
13
+
14
+ class Message(BaseModel):
15
+ """A message in a conversation or trace.
16
+
17
+ Used by both replay inputs and dataset step inputs.
18
+ """
19
+
20
+ role: Literal["system", "developer", "user", "assistant", "tool"]
21
+ content: str | None = None
22
+ content_ref: str | None = None
23
+ name: str | None = None
agentversion/cli.py ADDED
@@ -0,0 +1,407 @@
1
+ """CLI entry point for agentversion."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Literal, cast
6
+
7
+ import click
8
+ from pydantic import BaseModel
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from agentversion import __version__
13
+ from agentversion.constants import SPEC_VERSION
14
+
15
+ console = Console()
16
+
17
+
18
+ def _print_id_issues(data: dict[str, Any], kind: str) -> int:
19
+ """Run ID checks and print them. Returns the number of errors (so the
20
+ caller can ``raise SystemExit`` if non-zero)."""
21
+ from agentversion.ids import check_object_ids
22
+
23
+ errors = 0
24
+ for sev, code, message, path in check_object_ids(data, kind=kind):
25
+ icon = "[red]✗[/red]" if sev == "error" else "[yellow]⚠[/yellow]"
26
+ console.print(f" {icon} [{code}] at {path}: {message}")
27
+ if sev == "error":
28
+ errors += 1
29
+ return errors
30
+
31
+
32
+ @click.group()
33
+ @click.version_option(version=__version__, prog_name="agentversion")
34
+ def cli() -> None:
35
+ """AgentVersion — CLI tools.
36
+
37
+ An open specification for versioning agent runtimes
38
+ and keeping datasets valid.
39
+ """
40
+ pass
41
+
42
+
43
+ @cli.command()
44
+ @click.argument("manifest_file", type=click.Path(exists=True))
45
+ def validate(manifest_file: str) -> None:
46
+ """Validate a manifest file against the spec."""
47
+ from agentversion.validator import Severity, validate_manifest_file
48
+
49
+ result = validate_manifest_file(manifest_file)
50
+
51
+ if result.valid and not result.warnings:
52
+ console.print(f"[green]✓[/green] {manifest_file} is valid")
53
+ if result.manifest:
54
+ console.print(f" agent: [bold]{result.manifest.agent_name}[/bold]")
55
+ console.print(f" version: {result.manifest.version_label}")
56
+ console.print(f" hash: {result.manifest.identity.overall_hash[:24]}...")
57
+ return
58
+
59
+ for issue in result.issues:
60
+ if issue.severity == Severity.ERROR:
61
+ icon = "[red]✗[/red]"
62
+ elif issue.severity == Severity.WARNING:
63
+ icon = "[yellow]⚠[/yellow]"
64
+ else:
65
+ icon = "[blue]ℹ[/blue]"
66
+
67
+ path_str = f" at {issue.path}" if issue.path else ""
68
+ console.print(f" {icon} [{issue.code}]{path_str}: {issue.message}")
69
+
70
+ if result.valid:
71
+ console.print(f"\n[green]✓[/green] {manifest_file} is valid (with {len(result.warnings)} warning(s))")
72
+ else:
73
+ console.print(f"\n[red]✗[/red] {manifest_file} is invalid ({len(result.errors)} error(s))")
74
+ raise SystemExit(1)
75
+
76
+
77
+ @cli.command()
78
+ @click.argument("old_manifest", type=click.Path(exists=True))
79
+ @click.argument("new_manifest", type=click.Path(exists=True))
80
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
81
+ @click.option("--fail-on-breaking", is_flag=True, help="Exit with code 1 if breaking changes found")
82
+ @click.option("--compat", is_flag=True, help="Include compatibility recommendation")
83
+ def diff(old_manifest: str, new_manifest: str, output_json: bool, fail_on_breaking: bool, compat: bool) -> None:
84
+ """Diff two manifest files and classify changes."""
85
+ from agentversion.compatibility import classify_compatibility
86
+ from agentversion.diff import diff_manifests
87
+
88
+ with open(old_manifest) as f:
89
+ old_data = json.load(f)
90
+ with open(new_manifest) as f:
91
+ new_data = json.load(f)
92
+
93
+ result = diff_manifests(old_data, new_data)
94
+
95
+ if output_json:
96
+ output = json.loads(result.model_dump_json())
97
+ if compat:
98
+ report = classify_compatibility(result)
99
+ output["compatibility"] = json.loads(report.model_dump_json())
100
+ console.print_json(json.dumps(output, indent=2))
101
+ else:
102
+ if not result.changed_surfaces:
103
+ console.print("[green]✓[/green] No changes detected")
104
+ return
105
+
106
+ table = Table(title="Manifest Diff")
107
+ table.add_column("Surface", style="bold")
108
+ table.add_column("Change Type")
109
+ table.add_column("Details")
110
+
111
+ for change in result.changed_surfaces:
112
+ style = "red" if change.change_type == "breaking" else "green"
113
+ table.add_row(
114
+ change.surface,
115
+ f"[{style}]{change.change_type}[/{style}]",
116
+ "\n".join(change.details),
117
+ )
118
+ console.print(table)
119
+ console.print(
120
+ f"\n Breaking: {result.summary.breaking_surfaces} "
121
+ f"Non-breaking: {result.summary.non_breaking_surfaces}"
122
+ )
123
+
124
+ if compat:
125
+ report = classify_compatibility(result)
126
+ console.print(f"\n Recommendation: [bold]{report.recommended_decision}[/bold]")
127
+ console.print(f" {report.summary}")
128
+
129
+ if fail_on_breaking and result.summary.breaking_surfaces > 0:
130
+ raise SystemExit(1)
131
+
132
+
133
+ @cli.command()
134
+ def init() -> None:
135
+ """Initialize a new manifest file interactively."""
136
+ from agentversion.hasher import compute_and_set_hashes
137
+ from agentversion.ids import mint_id
138
+
139
+ agent_name = click.prompt("Agent name", type=str)
140
+ version_label = click.prompt("Version label", default="v1")
141
+ provider = click.prompt("Model provider (e.g. openai, anthropic, google)", type=str)
142
+ model = click.prompt("Model name (e.g. gpt-4o, claude-opus-4, gemini-2.0-flash)", type=str)
143
+
144
+ manifest = {
145
+ "spec_version": SPEC_VERSION,
146
+ "kind": "agent_manifest",
147
+ "manifest_id": mint_id("agent_manifest"),
148
+ "agent_name": agent_name,
149
+ "version_label": version_label,
150
+ "created_at": datetime.now(timezone.utc).isoformat(),
151
+ "description": f"Manifest for {agent_name} {version_label}",
152
+ "tags": [],
153
+ "identity": {
154
+ "overall_hash": "PLACEHOLDER",
155
+ "hash_algorithm": "jcs-sha256",
156
+ },
157
+ "contract": {
158
+ "prompt_stack": {
159
+ "system_prompt": {
160
+ "id": f"prompt_system_{agent_name}",
161
+ "version": "1",
162
+ "hash": "sha256:REPLACE_WITH_ACTUAL_HASH",
163
+ },
164
+ "reasoning_policy": "hidden",
165
+ },
166
+ "model_runtime": {
167
+ "provider": provider,
168
+ "model": model,
169
+ },
170
+ "tool_registry": {
171
+ "registry_version": "1",
172
+ "registry_hash": "sha256:REPLACE_WITH_ACTUAL_HASH",
173
+ "tools": [],
174
+ },
175
+ "workflow": {
176
+ "graph_name": f"{agent_name}-graph",
177
+ "graph_version": "1",
178
+ },
179
+ "output_contract": {
180
+ "version": "1",
181
+ "schema_hash": "sha256:REPLACE_WITH_ACTUAL_HASH",
182
+ "format": "text",
183
+ "strict": False,
184
+ },
185
+ "guardrails": None,
186
+ },
187
+ "extensions": {},
188
+ }
189
+
190
+ compute_and_set_hashes(manifest)
191
+
192
+ output_file = f"{agent_name}-manifest.json"
193
+ with open(output_file, "w") as f:
194
+ json.dump(manifest, f, indent=2)
195
+ console.print(f"[green]✓[/green] Created {output_file}")
196
+ console.print(f" hash: {manifest['identity']['overall_hash']}")
197
+
198
+
199
+ @cli.command()
200
+ @click.argument("manifest_file", type=click.Path(exists=True))
201
+ def hash(manifest_file: str) -> None:
202
+ """Compute the canonical hash of a manifest."""
203
+ from agentversion.hasher import hash_manifest
204
+
205
+ with open(manifest_file) as f:
206
+ data = json.load(f)
207
+
208
+ computed_hash = hash_manifest(data)
209
+ console.print(computed_hash)
210
+
211
+ existing = data.get("identity", {}).get("overall_hash")
212
+ if existing and existing != computed_hash:
213
+ console.print(f"[yellow]⚠[/yellow] Declared hash differs: {existing}")
214
+
215
+
216
+ @cli.command()
217
+ @click.argument("manifest_file", type=click.Path(exists=True))
218
+ @click.option("--to", "target_version", required=True, help="Target spec version (e.g. 1.1.0)")
219
+ @click.option("--in-place", is_flag=True, help="Rewrite the input file instead of printing to stdout")
220
+ def upgrade(manifest_file: str, target_version: str, in_place: bool) -> None:
221
+ """Upgrade a manifest to a newer spec version.
222
+
223
+ Within a major version there are no field-level migrations required, so
224
+ this is an identity upgrade: parse, set spec_version, re-emit. Refuses to
225
+ downgrade or cross major-version boundaries.
226
+ """
227
+ import re
228
+
229
+ semver_re = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
230
+ if not semver_re.match(target_version):
231
+ console.print(f"[red]✗[/red] --to must be MAJOR.MINOR.PATCH (got {target_version!r})")
232
+ raise SystemExit(2)
233
+
234
+ with open(manifest_file) as f:
235
+ data = json.load(f)
236
+
237
+ current = data.get("spec_version", "0.0.0")
238
+ m_cur = semver_re.match(current)
239
+ m_tgt = semver_re.match(target_version)
240
+ if not m_cur:
241
+ console.print(f"[red]✗[/red] manifest has invalid spec_version {current!r}")
242
+ raise SystemExit(2)
243
+
244
+ cur_tuple = tuple(int(x) for x in m_cur.groups())
245
+ tgt_tuple = tuple(int(x) for x in m_tgt.groups()) # type: ignore[union-attr]
246
+
247
+ if tgt_tuple < cur_tuple:
248
+ console.print(f"[red]✗[/red] refuse to downgrade ({current} → {target_version})")
249
+ raise SystemExit(2)
250
+ if tgt_tuple[0] != cur_tuple[0]:
251
+ console.print(
252
+ f"[red]✗[/red] cross-major upgrade not supported "
253
+ f"({current} → {target_version}); see CHANGELOG for the migration path"
254
+ )
255
+ raise SystemExit(2)
256
+
257
+ data["spec_version"] = target_version
258
+
259
+ if in_place:
260
+ with open(manifest_file, "w") as f:
261
+ json.dump(data, f, indent=2)
262
+ console.print(f"[green]✓[/green] {manifest_file}: {current} → {target_version}")
263
+ else:
264
+ console.print_json(json.dumps(data, indent=2))
265
+
266
+
267
+ # -- Sub-command groups for other spec objects --
268
+
269
+
270
+ @cli.group()
271
+ def decision() -> None:
272
+ """Compatibility decision commands."""
273
+ pass
274
+
275
+
276
+ @decision.command("validate")
277
+ @click.argument("decision_file", type=click.Path(exists=True))
278
+ def decision_validate(decision_file: str) -> None:
279
+ """Validate a compatibility decision file."""
280
+ from agentversion.decision import CompatibilityDecision
281
+
282
+ with open(decision_file) as f:
283
+ data = json.load(f)
284
+ try:
285
+ d = CompatibilityDecision.model_validate(data)
286
+ console.print(f"[green]✓[/green] Valid compatibility decision: {d.decision}")
287
+ console.print(f" subject: {d.subject.type}/{d.subject.id}")
288
+ if d.reason_codes:
289
+ console.print(f" reasons: {', '.join(d.reason_codes)}")
290
+ except Exception as e:
291
+ console.print(f"[red]✗[/red] Invalid compatibility decision: {e}")
292
+ raise SystemExit(1)
293
+
294
+ if _print_id_issues(data, "compatibility_decision") > 0:
295
+ raise SystemExit(1)
296
+
297
+
298
+ @decision.command("generate")
299
+ @click.argument("old_manifest", type=click.Path(exists=True))
300
+ @click.argument("new_manifest", type=click.Path(exists=True))
301
+ @click.option(
302
+ "--subject-type",
303
+ type=click.Choice(["task", "episode", "step", "dataset_item"]),
304
+ default="episode",
305
+ help="Subject type",
306
+ )
307
+ @click.option("--subject-id", default="ep_unknown", help="Subject ID")
308
+ def decision_generate(old_manifest: str, new_manifest: str, subject_type: str, subject_id: str) -> None:
309
+ """Auto-generate a compatibility decision from two manifests."""
310
+ from datetime import timezone
311
+
312
+ from agentversion.compatibility import classify_compatibility
313
+ from agentversion.decision import CompatibilityDecision, DecisionSubject
314
+ from agentversion.diff import diff_manifests
315
+
316
+ with open(old_manifest) as f:
317
+ old_data = json.load(f)
318
+ with open(new_manifest) as f:
319
+ new_data = json.load(f)
320
+
321
+ diff_result = diff_manifests(old_data, new_data)
322
+ report = classify_compatibility(diff_result)
323
+
324
+ cd = CompatibilityDecision(
325
+ decision_id=f"cdc_auto_{subject_id}",
326
+ subject=DecisionSubject(
327
+ type=cast(Literal["task", "episode", "step", "dataset_item"], subject_type),
328
+ id=subject_id,
329
+ ),
330
+ old_manifest_id=old_data.get("manifest_id", "unknown"),
331
+ target_manifest_id=new_data.get("manifest_id", "unknown"),
332
+ decision=report.recommended_decision,
333
+ reason_codes=report.reason_codes,
334
+ created_at=datetime.now(timezone.utc),
335
+ )
336
+
337
+ console.print_json(cd.model_dump_json(indent=2))
338
+
339
+
340
+ @cli.group()
341
+ def replay() -> None:
342
+ """Replay job commands."""
343
+ pass
344
+
345
+
346
+ @replay.command("validate")
347
+ @click.argument("job_file", type=click.Path(exists=True))
348
+ def replay_validate(job_file: str) -> None:
349
+ """Validate a replay job file."""
350
+ from agentversion.replay import ReplayJob
351
+
352
+ with open(job_file) as f:
353
+ data = json.load(f)
354
+ try:
355
+ job = ReplayJob.model_validate(data)
356
+ console.print(f"[green]✓[/green] Valid replay job: {job.replay_job_id}")
357
+ console.print(f" mode: {job.mode} priority: {job.priority}")
358
+ console.print(f" target: {job.target_manifest_id}")
359
+ except Exception as e:
360
+ console.print(f"[red]✗[/red] Invalid replay job: {e}")
361
+ raise SystemExit(1)
362
+
363
+ if _print_id_issues(data, "replay_job") > 0:
364
+ raise SystemExit(1)
365
+
366
+
367
+ @cli.group()
368
+ def dataset() -> None:
369
+ """Dataset commands."""
370
+ pass
371
+
372
+
373
+ @dataset.command("validate")
374
+ @click.argument("dataset_file", type=click.Path(exists=True))
375
+ def dataset_validate(dataset_file: str) -> None:
376
+ """Validate a dataset file (task, episode, step, or snapshot)."""
377
+ from agentversion.dataset import DatasetSnapshot, Episode, Step, Task
378
+
379
+ kind_map: dict[str, type[BaseModel]] = {
380
+ "task": Task,
381
+ "episode": Episode,
382
+ "step": Step,
383
+ "dataset_snapshot": DatasetSnapshot,
384
+ }
385
+
386
+ with open(dataset_file) as f:
387
+ data = json.load(f)
388
+
389
+ kind = data.get("kind")
390
+ if kind not in kind_map:
391
+ console.print(f"[red]✗[/red] Unknown kind: {kind!r} (expected one of {list(kind_map.keys())})")
392
+ raise SystemExit(1)
393
+
394
+ try:
395
+ model_cls = kind_map[kind]
396
+ obj = model_cls.model_validate(data)
397
+ console.print(f"[green]✓[/green] Valid {kind}: {getattr(obj, f'{kind}_id', 'ok')}")
398
+ except Exception as e:
399
+ console.print(f"[red]✗[/red] Invalid {kind}: {e}")
400
+ raise SystemExit(1)
401
+
402
+ if _print_id_issues(data, kind) > 0:
403
+ raise SystemExit(1)
404
+
405
+
406
+ if __name__ == "__main__":
407
+ cli()