Repository navigation
feat(subjects): distribution_digest, the digest a well-known skill index carries #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
099c2b8
de58607
1dbde6a
a5c5500
08cd011
3f98c22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| <!-- | ||
| Copyright (C) 2026 Intel Corporation | ||
| SPDX-License-Identifier: Apache-2.0 | ||
| --> | ||
|
|
||
| ### Added | ||
|
|
||
| - **`subject.distribution_digest` in the subject scorecard** — the `sha256:` digest a `.well-known/agent-skills/index.json` entry (agentskills PR #254) would carry for the subject: the bytes of `SKILL.md` when its directory holds nothing else (a directory ref or a `SKILL.md` file ref alike). A skill with resources is served as an archive, whose bytes a checkout cannot predict, so it gets `null` under either ref, and so does a ref that is not a local path. A client can match a published result to the skill it installed by this field. `ref_digest` is unchanged: a content digest of the tree, which no index carries. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import re | ||
| from datetime import datetime, timezone | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
@@ -76,11 +77,13 @@ def build_subject_scorecard( | |
| "kind": descriptor.subject.kind, | ||
| "ref": descriptor.subject.ref, | ||
| "ref_digest": _digest_ref(descriptor.subject.ref, base=Path.cwd()), | ||
| "distribution_digest": distribution_digest(descriptor.subject.ref, base=Path.cwd()), | ||
| "members": [ | ||
| { | ||
| "kind": member.kind, | ||
| "ref": member.ref, | ||
| "ref_digest": _digest_ref(member.ref, base=Path.cwd()), | ||
| "distribution_digest": distribution_digest(member.ref, base=Path.cwd()), | ||
| } | ||
| for member in descriptor.subject.members | ||
| ], | ||
|
|
@@ -138,6 +141,37 @@ def _digest_values(values: tuple[str, ...]) -> str: | |
| return "sha256:" + h.hexdigest() | ||
|
|
||
|
|
||
| def distribution_digest(ref: str | None, *, base: Path) -> str | None: | ||
| """The digest a ``.well-known/agent-skills`` index would publish for *ref*, or None. | ||
|
|
||
| That index (agentskills PR #254) hashes the bytes it serves: one ``SKILL.md`` for a | ||
| ``skill-md`` entry, the archive for an ``archive`` entry. Only the first can be derived | ||
| from a checkout. A ``SKILL.md`` is hashed as is when its directory holds nothing else, | ||
| whether the ref names the file or the directory. A skill whose directory holds resources | ||
| is served as an archive, whose bytes depend on how it was packed, so it gets None rather | ||
| than a digest no index carries, under either spelling of its ref. So does a ref that is | ||
| not a local path. ``ref_digest`` stays a content digest of the tree. | ||
| """ | ||
| if not ref: | ||
| return None | ||
| if re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", ref) and not ref.startswith("local:"): | ||
| return None | ||
| path = Path(ref.removeprefix("local:")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
antonwolfy marked this conversation as resolved.
Resolved
|
||
| if not path.is_absolute(): | ||
| path = base / path | ||
| if path.is_dir(): | ||
| skill_dir = path | ||
| elif path.name == "SKILL.md" and path.is_file(): | ||
| skill_dir = path.parent | ||
| else: | ||
| return None | ||
| skill = skill_dir / "SKILL.md" | ||
| # Any other entry makes it an archive; one stray file is enough, so stop at the second. | ||
| if [p.name for _, p in zip(range(2), skill_dir.iterdir(), strict=False)] != ["SKILL.md"] or not skill.is_file(): | ||
| return None | ||
| return "sha256:" + hashlib.sha256(skill.read_bytes()).hexdigest() | ||
|
|
||
|
|
||
| def _digest_ref(ref: str | None, *, base: Path) -> str | None: | ||
| if not ref: | ||
| return None | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # Copyright (C) 2026 Intel Corporation | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """subject.distribution_digest: the digest a .well-known/agent-skills index carries.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| from dokimi.subjects.scorecard import distribution_digest | ||
|
|
||
|
|
||
| def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path): | ||
| # agentskills PR #254: a skill-md index entry carries sha256 of the SKILL.md bytes it serves. | ||
| skill = tmp_path / "skill" | ||
| skill.mkdir() | ||
| (skill / "SKILL.md").write_bytes(b"---\r\nname: s\r\n---\r\nbody\r\n") | ||
| expected = "sha256:" + hashlib.sha256(b"---\r\nname: s\r\n---\r\nbody\r\n").hexdigest() | ||
|
|
||
| assert distribution_digest("skill", base=tmp_path) == expected | ||
| assert distribution_digest("local:skill/SKILL.md", base=tmp_path) == expected | ||
|
|
||
| # With resources the skill is served as an archive, whose bytes a checkout cannot predict. | ||
| (skill / "ref.md").write_text("more", encoding="utf-8") | ||
| assert distribution_digest("skill", base=tmp_path) is None | ||
| assert distribution_digest("skill/SKILL.md", base=tmp_path) is None # the same skill, by its file ref | ||
| # A ref that is not a local path has no served bytes to hash. | ||
| assert distribution_digest("context7:/org/lib", base=tmp_path) is None | ||
| assert distribution_digest(None, base=tmp_path) is None | ||
| # Only SKILL.md is served as a skill-md entry; another file has no index digest. | ||
| (tmp_path / "notes.md").write_text("x", encoding="utf-8") | ||
| assert distribution_digest("notes.md", base=tmp_path) is None | ||
|
|
||
|
|
||
| @pytest.mark.skipif(sys.platform == "win32", reason="a colon is invalid in a Windows directory name") | ||
| def test_a_ref_with_a_scheme_gets_no_digest_even_when_a_directory_collides(tmp_path: Path): | ||
| colliding = tmp_path / "context7:" / "org" / "lib" | ||
| colliding.mkdir(parents=True) | ||
|
Comment on lines
+41
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 08cd011: the colon-named collision test is skipped on win32, since a colon is not valid in a Windows directory name. The rest of the file still runs on every platform. Local: test_subjects_digest.py 4 passed; full suite 12025 passed, 8 skipped. |
||
| (colliding / "SKILL.md").write_bytes(b"body") | ||
|
|
||
| assert distribution_digest("context7:/org/lib", base=tmp_path) is None | ||
|
|
||
|
|
||
| def test_the_scorecard_carries_the_distribution_digest(tmp_path: Path, monkeypatch): | ||
| from dokimi.artifacts import validate_artifact | ||
| from dokimi.subjects import build_subject_scorecard, load_subject | ||
|
|
||
| (tmp_path / "skill").mkdir() | ||
| (tmp_path / "skill" / "SKILL.md").write_bytes(b"body") | ||
| (tmp_path / "subject.toml").write_text( | ||
| '[subject]\nid = "s"\nkind = "skill"\nref = "skill"\n\n' | ||
| '[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8") | ||
| monkeypatch.chdir(tmp_path) | ||
|
|
||
| scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path) | ||
|
|
||
| assert scorecard["subject"]["distribution_digest"] == "sha256:" + hashlib.sha256(b"body").hexdigest() | ||
| validate_artifact("subject_scorecard", scorecard) | ||
|
Comment on lines
+61
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
|
|
||
| def test_the_scorecard_carries_a_distribution_digest_per_member(tmp_path: Path, monkeypatch): | ||
| from dokimi.subjects import build_subject_scorecard, load_subject | ||
|
|
||
| (tmp_path / "standalone").mkdir() | ||
| (tmp_path / "standalone" / "SKILL.md").write_bytes(b"member body") | ||
| (tmp_path / "bundled").mkdir() | ||
| (tmp_path / "bundled" / "SKILL.md").write_bytes(b"bundled body") | ||
| (tmp_path / "bundled" / "ref.md").write_text("resource", encoding="utf-8") | ||
| (tmp_path / "subject.toml").write_text( | ||
| '[subject]\nid = "b"\nkind = "bundle"\n\n' | ||
| '[[subject.members]]\nkind = "skill"\nref = "standalone"\n\n' | ||
| '[[subject.members]]\nkind = "skill"\nref = "bundled/SKILL.md"\n\n' | ||
| '[[subject.members]]\nkind = "skill"\nref = "context7:/org/lib"\n\n' | ||
| '[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8") | ||
| monkeypatch.chdir(tmp_path) | ||
|
|
||
| scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path) | ||
|
|
||
| # The standalone member has a digest; the bundled one has resources, so neither its file nor its directory does. | ||
| digests = [member["distribution_digest"] for member in scorecard["subject"]["members"]] | ||
| assert digests == ["sha256:" + hashlib.sha256(b"member body").hexdigest(), None, None] | ||
Uh oh!
There was an error while loading. Please reload this page.