agentship-cli 0.0.1__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,391 @@
1
+ """The engine behind ``agentship verify`` — AgentShip's "verifiable agents" proof.
2
+
3
+ ``verify`` is the one themed report that proves two honesty claims at once:
4
+
5
+ 1. **No over-claims.** Every installed engine genuinely honours the capabilities it
6
+ declares (and fails fast when asked for one it does not) — the engine×capability
7
+ conformance grid from :mod:`agentship.conformance`.
8
+ 2. **The wired contracts hold.** A project's agents load, validate against their
9
+ engines, and the framework's cross-cutting promises (observability span tree,
10
+ service auth/tenant isolation, A2A card honesty) still stand.
11
+
12
+ Each check is one :class:`Section`. The cardinal rule is *declare, don't fake*
13
+ (DESIGN §11): a section whose optional dependency is not installed, or which has no
14
+ target to check, reports **SKIPPED with a reason** — never a fake green. Only a real
15
+ failure (an over-claim, an invalid spec, a broken contract) fails the run.
16
+
17
+ This module owns no Click surface; :func:`run_verification` returns a
18
+ :class:`VerifyReport` that the ``verify`` command in :mod:`agentship_cli.main`
19
+ renders and turns into an exit code.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable
25
+ from contextlib import AbstractContextManager
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ from agentship.conformance import CellResult, run_capability_grid
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Section:
34
+ """One line of the verify report: a named check and how it fared.
35
+
36
+ ``name`` is the human label shown in the report. ``passed``/``total`` count the
37
+ sub-checks that held out of those attempted (``0/0`` for a skipped section).
38
+ ``skipped_reason`` is ``None`` when the section actually ran; when set, the
39
+ section did not run (a missing optional dependency or no target to check) and the
40
+ reason is shown verbatim — a skip never fails the overall run. ``details`` carries
41
+ extra lines to surface under the section (e.g. each named over-claim).
42
+ """
43
+
44
+ name: str
45
+ passed: int
46
+ total: int
47
+ skipped_reason: str | None
48
+ details: tuple[str, ...] = ()
49
+
50
+ @property
51
+ def skipped(self) -> bool:
52
+ """Whether this section was skipped (declared, not faked) rather than run."""
53
+ return self.skipped_reason is not None
54
+
55
+ @property
56
+ def failed(self) -> bool:
57
+ """Whether this section ran and had a real failure (some sub-check did not hold).
58
+
59
+ A skipped section is never failed — that is the honesty rule: a missing code
60
+ path or dependency is reported as SKIPPED, never counted against the run.
61
+ """
62
+ return not self.skipped and self.passed < self.total
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class VerifyReport:
67
+ """The whole ``agentship verify`` result: every section plus the over-claim roll-up.
68
+
69
+ ``over_claims`` names each conformance ``prove`` cell that failed — an engine that
70
+ declared a capability it does not actually honour, the headline dishonesty
71
+ ``verify`` exists to catch. ``ok`` is true when no section had a *real* failure
72
+ (skips do not count), which the CLI turns into the process exit code.
73
+ """
74
+
75
+ sections: tuple[Section, ...]
76
+ over_claims: tuple[str, ...]
77
+
78
+ @property
79
+ def ok(self) -> bool:
80
+ """True when no section really failed — skipped sections never fail the run."""
81
+ return not any(section.failed for section in self.sections)
82
+
83
+
84
+ def _langgraph_offline() -> Callable[[str], AbstractContextManager] | None:
85
+ """Return the langgraph offline model provider, or ``None`` if the extra is absent.
86
+
87
+ The grid's positive cells for a model-backed engine must run without a real
88
+ provider key; :func:`agentship_langgraph.testing.offline` supplies the fake-model
89
+ seam. It lives in an engine extra, so we import it lazily and degrade to ``None``
90
+ (grid runs unpatched — fine for the model-free ``echo`` engine) when it is not
91
+ installed, rather than making langgraph a hard dependency of ``verify``.
92
+ """
93
+ try:
94
+ from agentship_langgraph.testing import offline
95
+ except ImportError:
96
+ return None
97
+ return offline
98
+
99
+
100
+ async def _section_capability_grid() -> tuple[Section, tuple[str, ...]]:
101
+ """Run the engine×capability conformance grid and summarise it as a section.
102
+
103
+ Every cell (positive ``prove`` and negative ``reject``) counts toward the section
104
+ total; a failed ``prove`` cell is additionally recorded as a named over-claim
105
+ (``engine.capability``) — the exact dishonesty ``verify`` is built to surface. The
106
+ grid never raises: each failure is a failing :class:`CellResult`, so this always
107
+ returns a complete section.
108
+ """
109
+ results: list[CellResult] = await run_capability_grid(offline=_langgraph_offline())
110
+ passed = sum(1 for cell in results if cell.passed)
111
+ over_claims = tuple(
112
+ f"{cell.engine}.{cell.capability}"
113
+ for cell in results
114
+ if cell.kind == "prove" and not cell.passed
115
+ )
116
+ details = tuple(f"over-claim: {name} declared but not honoured" for name in over_claims)
117
+ section = Section("engine×capability grid", passed, len(results), None, details)
118
+ return section, over_claims
119
+
120
+
121
+ def _section_spec_validation(agents_dir: Path | None) -> Section:
122
+ """Validate every spec in ``agents_dir`` with the same checks ``doctor`` runs.
123
+
124
+ Skipped (honestly, with a reason) when no ``--agents-dir`` was given — there is
125
+ nothing to validate. Otherwise each ``*.yaml`` is loaded and capability-gated via
126
+ the shared :func:`agentship_cli.main._check_agent` helper (no duplication of
127
+ doctor's logic): a spec passes iff that returns ``None``. Every invalid spec is
128
+ listed with its reason and the section fails.
129
+ """
130
+ if agents_dir is None:
131
+ return Section("spec validation", 0, 0, "no --agents-dir given (nothing to validate)")
132
+
133
+ # Imported here (not at module load) to avoid a circular import with main.py.
134
+ from agentship.errors import AgentShipError
135
+
136
+ from .main import _agent_files, _check_agent
137
+
138
+ files = _agent_files(agents_dir)
139
+ if not files:
140
+ return Section("spec validation", 0, 0, f"no *.yaml specs in {agents_dir}")
141
+
142
+ passed = 0
143
+ details: list[str] = []
144
+ for path in files:
145
+ try:
146
+ reason = _check_agent(path)
147
+ except AgentShipError as exc:
148
+ reason = str(exc)
149
+ if reason is None:
150
+ passed += 1
151
+ else:
152
+ details.append(f"invalid spec {path.name}: {reason}")
153
+ return Section("spec validation", passed, len(files), None, tuple(details))
154
+
155
+
156
+ async def _section_observability() -> Section:
157
+ """Assert one real agent run emits the frozen root ``agent`` span (CONF-OBS-1).
158
+
159
+ Skipped (with a reason) when the ``agentship-observability`` extra is not
160
+ installed. Otherwise it builds an ``echo`` agent — no model, no key — attaches a
161
+ :class:`RecordingObserver`, runs one turn, and asserts the recorded tree is
162
+ exactly one root whose name/kind are the frozen ``agent`` span from SEMCONV. The
163
+ model-free echo engine emits no child spans, so the contract checked here is the
164
+ root-span shape the span tree hangs off; richer child structure is proven in the
165
+ per-engine observability suite.
166
+ """
167
+ try:
168
+ from agentship.observability import RecordingObserver, SpanKind, semconv
169
+ except ImportError:
170
+ return Section(
171
+ "observability span-tree",
172
+ 0,
173
+ 0,
174
+ "agentship-observability not installed — pip install 'agentship-sdk[observability]'",
175
+ )
176
+
177
+ from agentship.runtime import build_agent
178
+ from agentship.spec import AgentSpec
179
+
180
+ checks: list[tuple[str, bool]] = []
181
+ obs = RecordingObserver()
182
+ agent = build_agent(AgentSpec(name="verify-obs", engine="echo"), observer=obs)
183
+ await agent.run("ping", user_id="verify")
184
+
185
+ checks.append(("exactly one root span", len(obs.roots) == 1))
186
+ if obs.roots:
187
+ root = obs.roots[0]
188
+ # The root span is named "agent <name>" so a backend's trace list identifies the agent;
189
+ # the check is the prefix, since the suffix is the agent's own name.
190
+ checks.append(
191
+ ("root span is named 'agent <name>'", root.name.startswith(semconv.SPAN_AGENT))
192
+ )
193
+ checks.append(("root span kind is AGENT", root.kind is SpanKind.AGENT))
194
+ # The trace view read-port surfaces the recorded tree the eval/audit phases read.
195
+ view = obs.trace_view()
196
+ span_names = [s.name for s in view.spans()]
197
+ checks.append(("root visible via trace_view()", root.name in span_names))
198
+
199
+ passed = sum(1 for _, ok in checks if ok)
200
+ details = tuple(f"failed: {label}" for label, ok in checks if not ok)
201
+ return Section("observability span-tree", passed, len(checks), None, details)
202
+
203
+
204
+ def _section_service_contracts(agents_dir: Path | None) -> Section:
205
+ """Assert the service enforces auth + tenant isolation (mirrors P04 conformance).
206
+
207
+ Skipped (with a reason) when the ``agentship-service`` extra is not installed or no
208
+ ``--agents-dir`` was given (the section is about serving a project's agents). When
209
+ it runs it builds the real app via ``create_app`` over an in-memory ``echo`` agent
210
+ and a two-tenant API-key table, then drives a FastAPI ``TestClient`` to assert the
211
+ same promises as ``test_phase04_service_security.py``:
212
+
213
+ - an unauthenticated invoke is 401 (no open ingress);
214
+ - a request without the agent's invoke scope is 403;
215
+ - tenant B cannot read (404) or cancel (403) a task tenant A created, while A can;
216
+ - the ``/healthz`` liveness probe is the sole public path.
217
+ """
218
+ if agents_dir is None:
219
+ return Section("service contracts", 0, 0, "no --agents-dir given (nothing to serve)")
220
+ try:
221
+ from agentship_service import AgentRegistry, create_app
222
+ from fastapi.testclient import TestClient
223
+ except ImportError:
224
+ return Section(
225
+ "service contracts",
226
+ 0,
227
+ 0,
228
+ "agentship-service not installed — pip install 'agentship-sdk[service]'",
229
+ )
230
+
231
+ import json
232
+
233
+ from agentship.auth import ApiKeyAuthProvider, EnvApiKeyStore
234
+ from agentship.runtime import build_agent
235
+ from agentship.spec import AgentSpec
236
+
237
+ keys = json.dumps(
238
+ [
239
+ {"key": "acme", "user": "u1", "tenant": "acme", "scopes": ["*"]},
240
+ {"key": "beta", "user": "u2", "tenant": "beta", "scopes": ["*"]},
241
+ {"key": "narrow", "user": "u3", "tenant": "acme", "scopes": ["agent:other:invoke"]},
242
+ ]
243
+ )
244
+ agents = AgentRegistry([build_agent(AgentSpec(name="support", engine="echo"))])
245
+ auth = ApiKeyAuthProvider(EnvApiKeyStore(raw=keys))
246
+ client = TestClient(create_app(auth=auth, agents=agents))
247
+
248
+ checks: list[tuple[str, bool]] = []
249
+ invoke = "/v1/agents/support:invoke"
250
+
251
+ def _invoke(key: str | None) -> int:
252
+ """POST an invoke with the given API key (or none) and return the status code."""
253
+ headers = {"x-api-key": key} if key is not None else {}
254
+ return client.post(invoke, headers=headers, json={"input": "hi"}).status_code
255
+
256
+ # (a) No unauthenticated ingress: a missing or unknown key is rejected.
257
+ checks.append(("unauthenticated invoke rejected (401)", _invoke(None) == 401))
258
+ checks.append(("unknown key rejected (401)", _invoke("nope") == 401))
259
+ # A valid key with the right scope authenticates; one missing the scope is 403.
260
+ checks.append(("valid key + scope allowed (200)", _invoke("acme") == 200))
261
+ checks.append(("missing scope denied (403)", _invoke("narrow") == 403))
262
+
263
+ # (b) Tenant isolation: B cannot read/cancel A's task; A can.
264
+ created = client.post(
265
+ "/v1/tasks", headers={"x-api-key": "acme"}, json={"agent": "support", "input": "do it"}
266
+ )
267
+ checks.append(("task created by tenant A (202)", created.status_code == 202))
268
+ if created.status_code == 202:
269
+ task_id = created.json()["id"]
270
+ checks.append(
271
+ (
272
+ "tenant B cannot read A's task (404)",
273
+ client.get(f"/v1/tasks/{task_id}", headers={"x-api-key": "beta"}).status_code
274
+ == 404,
275
+ )
276
+ )
277
+ checks.append(
278
+ (
279
+ "tenant B cannot cancel A's task (403)",
280
+ client.post(
281
+ f"/v1/tasks/{task_id}:cancel", headers={"x-api-key": "beta"}
282
+ ).status_code
283
+ == 403,
284
+ )
285
+ )
286
+ checks.append(
287
+ (
288
+ "owner A reads its own task (200)",
289
+ client.get(f"/v1/tasks/{task_id}", headers={"x-api-key": "acme"}).status_code
290
+ == 200,
291
+ )
292
+ )
293
+
294
+ # (c) The liveness probe is the only public path.
295
+ checks.append(("/healthz is public (200)", client.get("/healthz").status_code == 200))
296
+
297
+ passed = sum(1 for _, ok in checks if ok)
298
+ details = tuple(f"failed: {label}" for label, ok in checks if not ok)
299
+ return Section("service contracts", passed, len(checks), None, details)
300
+
301
+
302
+ def _section_a2a_interop(agents_dir: Path | None) -> Section:
303
+ """Assert every A2A-exposed spec renders a schema-valid, honest Agent Card.
304
+
305
+ Skipped (with a reason) when no ``--agents-dir`` was given or no loaded spec sets
306
+ ``a2a.expose: true`` — the interop layer is optional (Layer 0 agents never reach
307
+ the wire), so having nothing exposed is a legitimate skip, not a pass. For each
308
+ exposed agent it builds the card from the spec + the engine's real capabilities
309
+ (never hand-written) and asserts the required card fields are present, its
310
+ ``streaming`` claim matches the engine, and every declared security scheme is
311
+ advertised in ``securitySchemes`` — the "declare, don't fake" card honesty.
312
+ """
313
+ if agents_dir is None:
314
+ return Section("A2A interop", 0, 0, "no --agents-dir given (no agents to expose)")
315
+
316
+ from agentship.a2a.card import build_agent_card
317
+ from agentship.engines.base import ENGINES
318
+ from agentship.errors import AgentShipError
319
+ from agentship.spec import load_spec
320
+
321
+ from .main import _agent_files
322
+
323
+ exposed: list = []
324
+ for path in _agent_files(agents_dir):
325
+ try:
326
+ spec = load_spec(path)
327
+ except AgentShipError:
328
+ continue # invalid specs are the spec-validation section's job, not this one
329
+ a2a = getattr(spec, "a2a", None)
330
+ if a2a is not None and getattr(a2a, "expose", False):
331
+ exposed.append(spec)
332
+
333
+ if not exposed:
334
+ return Section("A2A interop", 0, 0, "no spec exposes a2a (interop layer optional)")
335
+
336
+ checks: list[tuple[str, bool]] = []
337
+ required = {"name", "url", "capabilities", "securitySchemes"}
338
+ for spec in exposed:
339
+ engine_cls = ENGINES.get(spec.engine)
340
+ if engine_cls is None:
341
+ checks.append((f"{spec.name}: engine {spec.engine!r} installed", False))
342
+ continue
343
+ caps = engine_cls.capabilities
344
+ card = build_agent_card(
345
+ spec,
346
+ caps,
347
+ base_url="https://verify.local",
348
+ security=spec.a2a.security,
349
+ oauth2=spec.a2a.oauth2,
350
+ )
351
+ body = card.model_dump(mode="json", by_alias=True)
352
+ checks.append((f"{spec.name}: card has required fields", required <= set(body)))
353
+ checks.append(
354
+ (
355
+ f"{spec.name}: card streaming matches engine",
356
+ body["capabilities"].get("streaming") == caps.streaming,
357
+ )
358
+ )
359
+ checks.append(
360
+ (
361
+ f"{spec.name}: advertises its security schemes",
362
+ all(scheme in body["securitySchemes"] for scheme in spec.a2a.security),
363
+ )
364
+ )
365
+
366
+ passed = sum(1 for _, ok in checks if ok)
367
+ details = tuple(f"failed: {label}" for label, ok in checks if not ok)
368
+ return Section("A2A interop", passed, len(checks), None, details)
369
+
370
+
371
+ async def run_verification(agents_dir: Path | None, *, live: bool) -> VerifyReport:
372
+ """Run every verify section and assemble the themed :class:`VerifyReport`.
373
+
374
+ ``agents_dir`` (from ``--agents-dir``) scopes the project-level sections (spec
375
+ validation, service contracts, A2A interop); when ``None`` those sections skip
376
+ honestly. ``live`` is threaded for future live-provider checks — today every
377
+ section is fully offline (the grid uses the fake-model seam, all agents use the
378
+ model-free ``echo`` engine), so a run needs no provider keys. Sections whose
379
+ optional dependency is absent report SKIPPED rather than failing.
380
+ """
381
+ _ = live # reserved: all current sections run offline; live checks land later.
382
+
383
+ grid_section, over_claims = await _section_capability_grid()
384
+ sections = (
385
+ grid_section,
386
+ _section_spec_validation(agents_dir),
387
+ await _section_observability(),
388
+ _section_service_contracts(agents_dir),
389
+ _section_a2a_interop(agents_dir),
390
+ )
391
+ return VerifyReport(sections, over_claims)
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentship-cli
3
+ Version: 0.0.1
4
+ Summary: AgentShip CLI — the `agentship` command that loads a YAML spec, builds the agent, and runs (or streams) one turn.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: agentship-core==0.0.1
8
+ Requires-Dist: agentship-service==0.0.1
9
+ Requires-Dist: click>=8
10
+ Requires-Dist: python-dotenv>=1
11
+ Requires-Dist: uvicorn[standard]>=0.30
12
+ Description-Content-Type: text/markdown
13
+
14
+ # agentship-cli
15
+
16
+ The `agentship` command-line interface. Owns the `agentship` console script; import as `agentship_cli`.
17
+
18
+ Commands:
19
+
20
+ - `agentship init [DIR]` — scaffold a new single-tenant project (`agents/assistant.yaml`, `.env.example`, `README.md`) in `DIR` (default `.`). Refuses to overwrite existing files.
21
+ - `agentship new-agent NAME [--engine langgraph] [--agents-dir agents]` — write one starter agent spec at `<agents-dir>/NAME.yaml`. Refuses to overwrite.
22
+ - `agentship doctor [--agents-dir DIR | FILE]` — validate agent specs against their engines' declared capabilities. Prints a per-agent `OK`/`✗` line and exits `1` if any agent is invalid. An engine whose package is not installed yields an actionable `pip install` hint (no traceback).
23
+ - `agentship run FILE --input ... [--stream] [--env-file ...]` — load a YAML spec, build the agent, run (or stream) one turn, and print the output.
24
+
25
+ Harness errors print as a clean `Error:`/status line with a non-zero exit code rather than a traceback; pass `--debug` (on `run`/`doctor`) to re-raise for the full trace.
@@ -0,0 +1,9 @@
1
+ agentship_cli/main.py,sha256=3bTEbeS1f4a0k_-ePoN1Rft4DRUsPR2EUK6_XV98gMY,42685
2
+ agentship_cli/migrations.py,sha256=GH71Jo_13EGeDdSoqdB2jG7bj8ApkTuKnwFzOFwUApk,3592
3
+ agentship_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ agentship_cli/scaffold.py,sha256=kYgia7cdcGNWhYbrzP71eoCv5TUsRmMXSZs99k3zATQ,10790
5
+ agentship_cli/verify.py,sha256=nyplEDuzXJ1lZE6PujjUTELlFFWrIqW8LTNZppl7vdY,16980
6
+ agentship_cli-0.0.1.dist-info/METADATA,sha256=W6ogNa1KorpL03HwNfuDCQyMY6F2nIquGquj6xf5m-M,1532
7
+ agentship_cli-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ agentship_cli-0.0.1.dist-info/entry_points.txt,sha256=JM6IhqoVtTkgi5ij5_cusBMEcZzZiwQNbHW_Yj4melU,54
9
+ agentship_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentship = agentship_cli.main:main