diff --git a/.github/workflows/agent-learning-proposal.yml b/.github/workflows/agent-learning-proposal.yml new file mode 100644 index 0000000..64b94f3 --- /dev/null +++ b/.github/workflows/agent-learning-proposal.yml @@ -0,0 +1,38 @@ +name: agent-learning-proposal + +on: + pull_request: + paths: + - "schemas/agent-learning-proposal.schema.json" + - "examples/agent-learning/proposal.example.json" + - "scripts/create_agent_learning_proposal.py" + - "scripts/validate_agent_learning_proposal.py" + - "scripts/validate_agent_learning_proposal_generator.py" + - ".github/workflows/agent-learning-proposal.yml" + - "README.md" + - "Makefile" + push: + branches: + - main + paths: + - "schemas/agent-learning-proposal.schema.json" + - "examples/agent-learning/proposal.example.json" + - "scripts/create_agent_learning_proposal.py" + - "scripts/validate_agent_learning_proposal.py" + - "scripts/validate_agent_learning_proposal_generator.py" + - ".github/workflows/agent-learning-proposal.yml" + - "README.md" + - "Makefile" + +jobs: + validate-agent-learning-proposal: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install validation dependency + run: python -m pip install jsonschema + - name: Validate AgentLearningProposal example and generator + run: make validate-agent-learning-proposal diff --git a/Makefile b/Makefile index 98a01f5..007cbfd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ PYTHON ?= python -.PHONY: validate-upstreams validate-python validate-deploy-assets validate local-preflight local-up local-smoke local-debug local-down +.PHONY: validate-upstreams validate-python validate-deploy-assets validate-agent-learning-proposal validate local-preflight local-up local-smoke local-debug local-down validate-upstreams: $(PYTHON) scripts/validate_upstreams.py third_party/upstreams.lock.yaml @@ -13,6 +13,10 @@ validate-python: validate-deploy-assets: $(PYTHON) scripts/validate_deploy_assets.py +validate-agent-learning-proposal: + $(PYTHON) scripts/validate_agent_learning_proposal.py + $(PYTHON) scripts/validate_agent_learning_proposal_generator.py + validate: validate-upstreams validate-python validate-deploy-assets local-preflight: diff --git a/README.md b/README.md index 7416ef1..509b9a4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,37 @@ This repository currently includes: - importer and validation scripts so upstream resolution happens in one controlled place instead of at runtime; - local M2 Mac Podman and Google Cloud review deployment scaffolding. +## Governed agent learning proposals + +Memory Mesh now carries the first review-only proposal contract for durable agent learnings discovered during guarded AgentPlane sessions. + +This is the bridge from agent execution evidence to durable repo/project memory without silent writeback: + +- AgentPlane remains the source of guarded workcell, invocation, and stop-gate evidence. +- guardrail-fabric remains the source of policy-decision evidence. +- Policy Fabric remains the source of break-glass approval evidence. +- Memory Mesh receives reviewable learning proposals. +- Durable writeback remains disabled unless explicitly approved by a later governance flow. + +The contract, example, validator, and generator live at: + +- `schemas/agent-learning-proposal.schema.json` +- `examples/agent-learning/proposal.example.json` +- `scripts/validate_agent_learning_proposal.py` +- `scripts/create_agent_learning_proposal.py` +- `scripts/validate_agent_learning_proposal_generator.py` + +Validate locally: + +```bash +python -m pip install jsonschema +make validate-agent-learning-proposal +``` + +The workflow `.github/workflows/agent-learning-proposal.yml` runs this validation when the proposal schema, example, generator, validator, workflow, README, or Makefile changes. + +The example enforces review-only proposal mode, pending human review, no raw sensitive payload storage, evidence references, policy decision references, repo-local operating-contract destinations, and disabled durable writeback. + ## Lampstand adapter-record promotion packets Memory Mesh now carries a review-only promotion-packet contract for Lampstand governed adapter records. diff --git a/examples/agent-learning/proposal.example.json b/examples/agent-learning/proposal.example.json new file mode 100644 index 0000000..e37221f --- /dev/null +++ b/examples/agent-learning/proposal.example.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "memory.mesh.agent-learning/v1", + "kind": "AgentLearningProposal", + "proposalId": "urn:srcos:memory-proposal:agent-learning-example", + "createdAt": "2026-05-06T00:00:00Z", + "sourceSession": { + "sessionRef": "urn:srcos:session:agent-learning-example", + "taskRef": "urn:srcos:task:agent-learning-example", + "repo": "SocioProphet/agentplane", + "agentRef": "agentplane:guarded-command-invocation", + "workcellArtifactRef": "urn:srcos:artifact:guarded-workcell:example", + "invocationArtifactRef": "urn:srcos:artifact:guarded-invocation:example", + "stopGateArtifactRef": "urn:srcos:artifact:stop-gate:example" + }, + "destination": { + "scope": "repo", + "targetRef": "SocioProphet/agentplane", + "path": "AGENTS.md", + "memoryNamespace": "repo:SocioProphet/agentplane" + }, + "proposalMode": "review_only", + "learningType": "playbook-update", + "learning": { + "title": "Document stop-gate failure remediation in repo playbook", + "summary": "When AgentPlane stop-gate evaluation fails because the branch has no upstream, the agent should push the branch or explicitly request a no-PR/no-push exception rather than claiming completion.", + "rationale": "The stop-gate evaluator now treats missing upstream evidence as a hard failure. Capturing the remediation in the repo playbook reduces repeat false-done attempts.", + "proposedDiff": { + "format": "markdown-section", + "content": "### Agent completion gate remediation\n\nIf the stop gate reports `branch-pushed` failure, do not claim completion. Push the branch, open or update the PR, then rerun `tools/evaluate_stop_gate.py`. If branch push is intentionally disallowed, request a scoped PolicyFabric BreakGlassOverride and reference it in the stop-gate artifact.\n" + }, + "conflictsWith": [], + "confidence": 0.91, + "retention": "repo-durable" + }, + "review": { + "required": true, + "status": "pending", + "reviewerRefs": [ + "human:repo-maintainer" + ], + "approvalRef": null, + "reviewNotes": "Review before modifying AGENTS.md." + }, + "evidenceRefs": [ + "urn:srcos:artifact:stop-gate:example", + "urn:srcos:artifact:guarded-invocation:example" + ], + "policyDecisionRefs": [ + "sourceos/core/baseline-allow", + "agentplane/stop-gate/branch-pushed" + ], + "redaction": { + "rawSensitivePayloadStored": false, + "redactionSummary": "No raw session transcript or secret-bearing payload is stored in this proposal." + }, + "writeback": { + "enabled": false, + "performed": false, + "writebackRef": null + } +} diff --git a/schemas/agent-learning-proposal.schema.json b/schemas/agent-learning-proposal.schema.json new file mode 100644 index 0000000..6187a1d --- /dev/null +++ b/schemas/agent-learning-proposal.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.socioprophet.org/memory-mesh/agent-learning-proposal.schema.json", + "title": "Memory Mesh Agent Learning Proposal", + "type": "object", + "required": [ + "apiVersion", + "kind", + "proposalId", + "createdAt", + "sourceSession", + "destination", + "proposalMode", + "learningType", + "learning", + "review", + "evidenceRefs", + "policyDecisionRefs" + ], + "properties": { + "apiVersion": { "const": "memory.mesh.agent-learning/v1" }, + "kind": { "const": "AgentLearningProposal" }, + "proposalId": { "type": "string", "minLength": 1 }, + "createdAt": { "type": "string", "format": "date-time" }, + "sourceSession": { + "type": "object", + "required": ["sessionRef", "taskRef", "repo", "agentRef"], + "properties": { + "sessionRef": { "type": "string", "minLength": 1 }, + "taskRef": { "type": "string", "minLength": 1 }, + "repo": { "type": "string", "minLength": 1 }, + "agentRef": { "type": "string", "minLength": 1 }, + "workcellArtifactRef": { "type": ["string", "null"] }, + "invocationArtifactRef": { "type": ["string", "null"] }, + "stopGateArtifactRef": { "type": ["string", "null"] } + }, + "additionalProperties": false + }, + "destination": { + "type": "object", + "required": ["scope", "targetRef", "path"], + "properties": { + "scope": { "enum": ["repo", "project", "user", "organization", "enterprise"] }, + "targetRef": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "memoryNamespace": { "type": ["string", "null"] } + }, + "additionalProperties": false + }, + "proposalMode": { "enum": ["review_only", "approved_writeback"] }, + "learningType": { "enum": ["operational-fact", "playbook-update", "policy-note", "tooling-note", "risk-note", "architecture-note"] }, + "learning": { + "type": "object", + "required": ["title", "summary", "rationale", "proposedDiff"], + "properties": { + "title": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "rationale": { "type": "string", "minLength": 1 }, + "proposedDiff": { + "type": "object", + "required": ["format", "content"], + "properties": { + "format": { "enum": ["unified-diff", "markdown-section", "json-patch", "none"] }, + "content": { "type": "string" } + }, + "additionalProperties": false + }, + "conflictsWith": { "type": "array", "items": { "type": "string" } }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, + "retention": { "enum": ["session-only", "repo-durable", "project-durable", "org-durable", "enterprise-durable"] } + }, + "additionalProperties": false + }, + "review": { + "type": "object", + "required": ["required", "status", "reviewerRefs", "approvalRef"], + "properties": { + "required": { "type": "boolean" }, + "status": { "enum": ["pending", "approved", "rejected", "superseded"] }, + "reviewerRefs": { "type": "array", "items": { "type": "string" } }, + "approvalRef": { "type": ["string", "null"] }, + "reviewNotes": { "type": ["string", "null"] } + }, + "additionalProperties": false + }, + "evidenceRefs": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "policyDecisionRefs": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "redaction": { + "type": "object", + "required": ["rawSensitivePayloadStored", "redactionSummary"], + "properties": { + "rawSensitivePayloadStored": { "type": "boolean" }, + "redactionSummary": { "type": "string" } + }, + "additionalProperties": false + }, + "writeback": { + "type": "object", + "required": ["enabled", "performed", "writebackRef"], + "properties": { + "enabled": { "type": "boolean" }, + "performed": { "type": "boolean" }, + "writebackRef": { "type": ["string", "null"] } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/scripts/create_agent_learning_proposal.py b/scripts/create_agent_learning_proposal.py new file mode 100644 index 0000000..5d79d4e --- /dev/null +++ b/scripts/create_agent_learning_proposal.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Create review-only AgentLearningProposal artifacts. + +This script never writes durable memory. It produces a reviewable JSON proposal +that can later be approved, rejected, or superseded by a human/governance flow. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def build_proposal(args: argparse.Namespace) -> dict: + proposal_id = args.proposal_id or f"urn:srcos:memory-proposal:{args.session_ref.split(':')[-1]}" + evidence_refs = args.evidence_ref or [] + policy_refs = args.policy_decision_ref or [] + return { + "apiVersion": "memory.mesh.agent-learning/v1", + "kind": "AgentLearningProposal", + "proposalId": proposal_id, + "createdAt": args.created_at or utc_now(), + "sourceSession": { + "sessionRef": args.session_ref, + "taskRef": args.task_ref, + "repo": args.repo, + "agentRef": args.agent_ref, + "workcellArtifactRef": args.workcell_artifact_ref, + "invocationArtifactRef": args.invocation_artifact_ref, + "stopGateArtifactRef": args.stop_gate_artifact_ref, + }, + "destination": { + "scope": args.scope, + "targetRef": args.target_ref, + "path": args.path, + "memoryNamespace": args.memory_namespace, + }, + "proposalMode": "review_only", + "learningType": args.learning_type, + "learning": { + "title": args.title, + "summary": args.summary, + "rationale": args.rationale, + "proposedDiff": { + "format": args.diff_format, + "content": args.diff_content, + }, + "conflictsWith": args.conflicts_with or [], + "confidence": args.confidence, + "retention": args.retention, + }, + "review": { + "required": True, + "status": "pending", + "reviewerRefs": args.reviewer_ref or ["human:repo-maintainer"], + "approvalRef": None, + "reviewNotes": args.review_notes, + }, + "evidenceRefs": evidence_refs, + "policyDecisionRefs": policy_refs, + "redaction": { + "rawSensitivePayloadStored": False, + "redactionSummary": args.redaction_summary, + }, + "writeback": { + "enabled": False, + "performed": False, + "writebackRef": None, + }, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create a review-only Memory Mesh AgentLearningProposal artifact.") + parser.add_argument("--proposal-id") + parser.add_argument("--created-at") + parser.add_argument("--session-ref", required=True) + parser.add_argument("--task-ref", required=True) + parser.add_argument("--repo", required=True) + parser.add_argument("--agent-ref", required=True) + parser.add_argument("--workcell-artifact-ref") + parser.add_argument("--invocation-artifact-ref") + parser.add_argument("--stop-gate-artifact-ref") + parser.add_argument("--scope", choices=["repo", "project", "user", "organization", "enterprise"], default="repo") + parser.add_argument("--target-ref", required=True) + parser.add_argument("--path", required=True) + parser.add_argument("--memory-namespace") + parser.add_argument("--learning-type", choices=["operational-fact", "playbook-update", "policy-note", "tooling-note", "risk-note", "architecture-note"], default="playbook-update") + parser.add_argument("--title", required=True) + parser.add_argument("--summary", required=True) + parser.add_argument("--rationale", required=True) + parser.add_argument("--diff-format", choices=["unified-diff", "markdown-section", "json-patch", "none"], default="markdown-section") + parser.add_argument("--diff-content", required=True) + parser.add_argument("--conflicts-with", action="append") + parser.add_argument("--confidence", type=float, default=0.75) + parser.add_argument("--retention", choices=["session-only", "repo-durable", "project-durable", "org-durable", "enterprise-durable"], default="repo-durable") + parser.add_argument("--reviewer-ref", action="append") + parser.add_argument("--review-notes") + parser.add_argument("--evidence-ref", action="append", required=True) + parser.add_argument("--policy-decision-ref", action="append", required=True) + parser.add_argument("--redaction-summary", default="No raw sensitive payload stored in the proposal.") + parser.add_argument("--out", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if not 0 <= args.confidence <= 1: + raise SystemExit("--confidence must be between 0 and 1") + proposal = build_proposal(args) + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(proposal, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(proposal, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_agent_learning_proposal.py b/scripts/validate_agent_learning_proposal.py new file mode 100644 index 0000000..3652b60 --- /dev/null +++ b/scripts/validate_agent_learning_proposal.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Validate AgentLearningProposal examples or generated proposal files.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from jsonschema import Draft202012Validator + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = ROOT / "schemas" / "agent-learning-proposal.schema.json" +DEFAULT_EXAMPLE = ROOT / "examples" / "agent-learning" / "proposal.example.json" + + +def load_json(path: Path): + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def validate_proposal(example: dict, *, source_label: str) -> int: + if example["proposalMode"] != "review_only": + print(f"{source_label}: Agent learning proposal must remain review_only by default.") + return 1 + + if example["review"]["required"] is not True: + print(f"{source_label}: Agent learning proposals must require review by default.") + return 1 + + if example["review"]["status"] != "pending": + print(f"{source_label}: Proposal must remain pending until human review.") + return 1 + + if example.get("writeback", {}).get("enabled") is not False: + print(f"{source_label}: Proposal must not enable durable writeback.") + return 1 + + if example.get("writeback", {}).get("performed") is not False: + print(f"{source_label}: Proposal must not perform durable writeback.") + return 1 + + if example.get("redaction", {}).get("rawSensitivePayloadStored") is not False: + print(f"{source_label}: Proposal must not store raw sensitive payloads by default.") + return 1 + + if not example.get("evidenceRefs"): + print(f"{source_label}: Proposal requires evidenceRefs.") + return 1 + + if not example.get("policyDecisionRefs"): + print(f"{source_label}: Proposal requires policyDecisionRefs.") + return 1 + + learning = example["learning"] + retention = learning.get("retention") + if retention and not str(retention).endswith("durable"): + print(f"{source_label}: Example learning should target durable reviewed memory, not session-only memory.") + return 1 + + destination_path = example["destination"]["path"] + if destination_path not in {"AGENTS.md", "SOURCEOS.md"} and not destination_path.startswith(".sourceos/"): + print(f"{source_label}: Destination must be a repo-local operating contract or .sourceos path.") + return 1 + + diff = learning["proposedDiff"] + if diff["format"] != "markdown-section" or not diff["content"].strip(): + print(f"{source_label}: Proposal must include a non-empty markdown-section proposedDiff.") + return 1 + + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Validate AgentLearningProposal artifact.") + parser.add_argument("--proposal", default=str(DEFAULT_EXAMPLE), help="Proposal JSON file to validate") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + proposal_path = Path(args.proposal).resolve() + schema = load_json(SCHEMA) + example = load_json(proposal_path) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + errors = sorted(validator.iter_errors(example), key=lambda error: list(error.path)) + if errors: + print("Agent learning proposal failed validation:") + for error in errors: + location = ".".join(str(part) for part in error.path) or "" + print(f" - {location}: {error.message}") + return 1 + + result = validate_proposal(example, source_label=str(proposal_path)) + if result != 0: + return result + + print(f"Agent learning proposal validates against schema: {proposal_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_agent_learning_proposal_generator.py b/scripts/validate_agent_learning_proposal_generator.py new file mode 100644 index 0000000..094c465 --- /dev/null +++ b/scripts/validate_agent_learning_proposal_generator.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Smoke-test AgentLearningProposal generation and validation.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GENERATOR = ROOT / "scripts" / "create_agent_learning_proposal.py" +VALIDATOR = ROOT / "scripts" / "validate_agent_learning_proposal.py" + + +def die(message: str) -> None: + print(f"Agent learning proposal generator smoke failed: {message}") + raise SystemExit(1) + + +def run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, cwd=ROOT, text=True, capture_output=True, check=False) + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "proposal.json" + generated = run([ + sys.executable, + str(GENERATOR), + "--session-ref", "urn:srcos:session:generator-smoke", + "--task-ref", "urn:srcos:task:generator-smoke", + "--repo", "SocioProphet/agentplane", + "--agent-ref", "agentplane:guarded-command-invocation", + "--target-ref", "SocioProphet/agentplane", + "--path", "AGENTS.md", + "--title", "Capture guarded invocation remediation", + "--summary", "Document that guarded invocations require a passing or waived stop gate before completion.", + "--rationale", "Agents must not treat command success alone as task completion.", + "--diff-content", "### Guarded invocation completion\n\nA guarded invocation is complete only when its command exits zero and the stop gate returns pass or waived.\n", + "--evidence-ref", "urn:srcos:artifact:guarded-invocation:generator-smoke", + "--policy-decision-ref", "agentplane/stop-gate/guardrail-clear", + "--out", str(out), + ]) + if generated.returncode != 0: + die(f"generator failed: stdout={generated.stdout} stderr={generated.stderr}") + if not out.exists(): + die("generator did not write output file") + + proposal = json.loads(out.read_text(encoding="utf-8")) + if proposal["proposalMode"] != "review_only": + die("generated proposal is not review_only") + if proposal["writeback"]["enabled"] is not False or proposal["writeback"]["performed"] is not False: + die("generated proposal enabled or performed writeback") + if proposal["redaction"]["rawSensitivePayloadStored"] is not False: + die("generated proposal stores raw sensitive payloads") + + validated = run([sys.executable, str(VALIDATOR), "--proposal", str(out)]) + if validated.returncode != 0: + die(f"generated proposal failed validation: stdout={validated.stdout} stderr={validated.stderr}") + + print("Agent learning proposal generator smoke validates.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())