diff --git a/pyproject.toml b/pyproject.toml index 9fc8ee7..078c492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ matrix = [ agent-term = "agent_term.cli:main" agent-term-check = "agent_term.health_cli:main" agent-term-dispatch = "agent_term.dispatch_cli:main" +agent-term-interaction = "agent_term.interaction_cli:main" agent-term-matrix = "agent_term.matrix_cli:main" agent-term-smoke = "agent_term.operator_smoke_cli:main" agent-term-snapshot = "agent_term.snapshot_cli:main" diff --git a/src/agent_term/interaction.py b/src/agent_term/interaction.py new file mode 100644 index 0000000..2665d06 --- /dev/null +++ b/src/agent_term/interaction.py @@ -0,0 +1,272 @@ +"""SourceOS interaction event support for AgentTerm. + +AgentTerm is the operator surface. This module lets the terminal ingest and render the +shared SourceOSInteractionEvent contract without becoming the memory, policy, routing, +or execution authority. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agent_term.events import AgentTermEvent + +JsonObject = dict[str, Any] + +REQUIRED_TOP_LEVEL = { + "interactionEventId", + "type", + "specVersion", + "eventClass", + "occurredAt", + "surface", + "mode", + "session", + "actor", + "payloadMode", + "governanceTrace", +} + +REQUIRED_GOVERNANCE = {"policyAdmitted", "memoryWritten"} + + +def load_interaction_event(path: Path | str) -> JsonObject: + """Load a SourceOSInteractionEvent JSON object from disk.""" + + with Path(path).open("r", encoding="utf-8") as handle: + value = json.load(handle) + + if not isinstance(value, dict): + raise ValueError("SourceOSInteractionEvent payload must be a JSON object") + return value + + +def validate_interaction_event(event: JsonObject) -> list[str]: + """Return structural validation errors for AgentTerm's render/ingest boundary. + + This is intentionally a focused local check rather than a full JSON Schema validator. + Full schema validation belongs to sourceos-spec CI. AgentTerm needs enough validation + to fail closed before rendering or recording malformed events. + """ + + errors: list[str] = [] + + for field in sorted(REQUIRED_TOP_LEVEL): + if field not in event: + errors.append(f"missing top-level field: {field}") + + if event.get("type") != "SourceOSInteractionEvent": + errors.append("type must be SourceOSInteractionEvent") + + surface = event.get("surface") + if not isinstance(surface, dict): + errors.append("surface must be an object") + else: + if not surface.get("surfaceKind"): + errors.append("surface.surfaceKind is required") + if not surface.get("sourcePlane"): + errors.append("surface.sourcePlane is required") + + session = event.get("session") + if not isinstance(session, dict): + errors.append("session must be an object") + elif not session.get("sessionId"): + errors.append("session.sessionId is required") + + actor = event.get("actor") + if not isinstance(actor, dict): + errors.append("actor must be an object") + else: + if not actor.get("actorRef"): + errors.append("actor.actorRef is required") + if not actor.get("actorKind"): + errors.append("actor.actorKind is required") + + governance = event.get("governanceTrace") + if not isinstance(governance, dict): + errors.append("governanceTrace must be an object") + else: + for field in sorted(REQUIRED_GOVERNANCE): + if field not in governance: + errors.append(f"governanceTrace.{field} is required") + + return errors + + +def require_valid_interaction_event(event: JsonObject) -> None: + """Raise a ValueError if the event fails local ingest checks.""" + + errors = validate_interaction_event(event) + if errors: + raise ValueError("; ".join(errors)) + + +def render_interaction_event(event: JsonObject) -> str: + """Render a SourceOSInteractionEvent as an operator-readable governance trace.""" + + require_valid_interaction_event(event) + + surface = _object(event, "surface") + session = _object(event, "session") + actor = _object(event, "actor") + task = _nullable_object(event, "task") + steering = _nullable_object(event, "steeringIntent") + governance = _object(event, "governanceTrace") + + lines = [ + "SourceOS interaction event", + f" id: {event['interactionEventId']}", + f" class: {event['eventClass']}", + f" occurred: {event['occurredAt']}", + f" mode: {event['mode']}", + f" surface: {surface.get('surfaceKind')} ({surface.get('sourcePlane')})", + f" session: {session.get('sessionId')}", + f" actor: {actor.get('actorRef')} [{actor.get('actorKind')}]", + ] + + if session.get("roomRef") or session.get("threadRef") or session.get("workroomRef"): + lines.extend( + [ + f" room: {session.get('roomRef') or 'none'}", + f" thread: {session.get('threadRef') or 'none'}", + f" workroom: {session.get('workroomRef') or 'none'}", + f" topic: {session.get('topicRef') or 'none'}", + ] + ) + + if task: + latency = task.get("latencyMs") if task.get("latencyMs") is not None else "none" + lines.extend( + [ + " task:", + f" status: {task.get('status')}", + f" provider: {task.get('provider') or 'none'}", + f" model_hint: {task.get('modelHint') or 'none'}", + f" model_routed: {task.get('modelRouted') or 'none'}", + f" latency_ms: {latency}", + ] + ) + + if steering: + lines.extend( + [ + " steering:", + f" kind: {steering.get('steeringKind')}", + f" status: {steering.get('status')}", + f" feature: {steering.get('featureRef') or 'none'}", + ] + ) + + lines.extend( + [ + " governance:", + f" policy: {'admitted' if governance.get('policyAdmitted') else 'blocked'}", + f" policy_ref: {governance.get('policyRef') or 'none'}", + f" memory: {'written' if governance.get('memoryWritten') else 'not-written'}", + f" memory_scope: {governance.get('memoryScopeRef') or 'none'}", + f" request_hash: {governance.get('requestHash') or 'none'}", + f" evidence_hash: {governance.get('evidenceHash') or 'none'}", + f" provider_route_evidence: {governance.get('providerRouteEvidenceRef') or 'none'}", + f" agentplane_run: {governance.get('agentPlaneRunRef') or 'none'}", + f" replay: {governance.get('replayRef') or 'none'}", + ] + ) + + _append_list(lines, "policy_decisions", governance.get("policyDecisionRefs")) + _append_list(lines, "grants", governance.get("grantRefs")) + _append_list(lines, "context_packs", governance.get("contextPackRefs")) + _append_list(lines, "evidence", governance.get("evidenceRefs")) + _append_list(lines, "redactions", event.get("redactionRefs")) + + payload = _nullable_object(event, "payload") + if payload and isinstance(payload.get("summary"), str): + lines.extend([" payload:", f" summary: {payload['summary']}"]) + elif event.get("payloadMode"): + lines.extend([" payload:", f" mode: {event['payloadMode']}"]) + + return "\n".join(lines) + + +def interaction_to_agent_term_event( + event: JsonObject, + *, + channel: str = "!sourceos-interaction", + sender: str = "@agent-term", +) -> AgentTermEvent: + """Convert a SourceOSInteractionEvent into AgentTerm's local event model.""" + + require_valid_interaction_event(event) + + surface = _object(event, "surface") + session = _object(event, "session") + governance = _object(event, "governanceTrace") + task = _nullable_object(event, "task") + payload = _nullable_object(event, "payload") + + body = _summary_body(event, payload, task) + thread_id = session.get("threadRef") if isinstance(session.get("threadRef"), str) else None + metadata: JsonObject = { + "sourceos_interaction_event_id": event["interactionEventId"], + "event_class": event["eventClass"], + "mode": event["mode"], + "surface": surface, + "session": session, + "task": task, + "governanceTrace": governance, + "payloadMode": event["payloadMode"], + "sourceEventRefs": event.get("sourceEventRefs", []), + "redactionRefs": event.get("redactionRefs", []), + } + + return AgentTermEvent( + channel=channel, + sender=sender, + kind="sourceos_interaction", + source=str(surface.get("surfaceKind") or "sourceos-interaction"), + body=body, + thread_id=thread_id, + metadata=metadata, + ) + + +def _summary_body(event: JsonObject, payload: JsonObject | None, task: JsonObject | None) -> str: + if payload and isinstance(payload.get("summary"), str): + return payload["summary"] + if task: + status = task.get("status", "unknown") + provider = task.get("provider") or "unknown-provider" + return f"{event['eventClass']} status={status} provider={provider}" + return str(event["eventClass"]) + + +def _object(event: JsonObject, key: str) -> JsonObject: + value = event.get(key) + if not isinstance(value, dict): + raise ValueError(f"{key} must be an object") + return value + + +def _nullable_object(event: JsonObject, key: str) -> JsonObject | None: + value = event.get(key) + if value is None: + return None + if not isinstance(value, dict): + raise ValueError(f"{key} must be an object or null") + return value + + +def _append_list(lines: list[str], label: str, value: Any) -> None: + if not value: + lines.append(f" {label}: none") + return + if not isinstance(value, list): + lines.append(f" {label}: invalid") + return + if not value: + lines.append(f" {label}: none") + return + lines.append(f" {label}:") + for item in value: + lines.append(f" - {item}") diff --git a/src/agent_term/interaction_cli.py b/src/agent_term/interaction_cli.py new file mode 100644 index 0000000..6530561 --- /dev/null +++ b/src/agent_term/interaction_cli.py @@ -0,0 +1,82 @@ +"""CLI helper for rendering SourceOSInteractionEvent payloads.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from agent_term.interaction import ( + interaction_to_agent_term_event, + load_interaction_event, + render_interaction_event, +) +from agent_term.store import DEFAULT_DB_PATH, EventStore + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="agent-term-interaction", + description="Render or record SourceOSInteractionEvent governance traces.", + ) + parser.add_argument( + "--db", + default=str(DEFAULT_DB_PATH), + help="Path to the local AgentTerm SQLite event log.", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + render = subparsers.add_parser("render", help="Render an interaction event JSON file.") + render.add_argument("path", type=Path) + + record = subparsers.add_parser( + "record", + help="Record an interaction event JSON file in the AgentTerm event log.", + ) + record.add_argument("path", type=Path) + record.add_argument("--channel", default="!sourceos-interaction") + record.add_argument("--sender", default="@agent-term") + + return parser + + +def cmd_render(path: Path) -> int: + event = load_interaction_event(path) + print(render_interaction_event(event)) + return 0 + + +def cmd_record(path: Path, db_path: Path, channel: str, sender: str) -> int: + event = load_interaction_event(path) + agent_term_event = interaction_to_agent_term_event( + event, + channel=channel, + sender=sender, + ) + store = EventStore(db_path) + try: + store.append(agent_term_event) + finally: + store.close() + print(render_interaction_event(event)) + print(f"recorded: {agent_term_event.event_id}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "render": + return cmd_render(args.path) + + if args.command == "record": + return cmd_record(args.path, Path(args.db), args.channel, args.sender) + + parser.error(f"unknown command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/fixtures/sourceos_interaction_event.json b/tests/fixtures/sourceos_interaction_event.json new file mode 100644 index 0000000..661b728 --- /dev/null +++ b/tests/fixtures/sourceos_interaction_event.json @@ -0,0 +1,97 @@ +{ + "interactionEventId": "urn:srcos:interaction-event:noetica-standalone-complete-0001", + "type": "SourceOSInteractionEvent", + "specVersion": "2.0.0", + "eventClass": "interaction.task_completed", + "occurredAt": "2026-05-22T16:20:00Z", + "surface": { + "surfaceKind": "noetica", + "sourcePlane": "SocioProphet/Noetica", + "clientRef": "web:noetica:local-demo" + }, + "mode": "standalone", + "session": { + "sessionId": "noetica-session-local", + "conversationRef": "urn:srcos:conversation:noetica-local-demo", + "roomRef": null, + "threadRef": "urn:srcos:thread:noetica-local-demo", + "workroomRef": "urn:srcos:workroom:professional-intelligence-demo", + "topicRef": "urn:srcos:topic:professional-intelligence", + "opsHistoryEventRef": "urn:srcos:ops-history-event:noetica-local-demo-0001" + }, + "actor": { + "actorRef": "urn:srcos:subject:user:operator", + "actorKind": "human", + "agentRegistryRef": null, + "onBehalfOfRef": "urn:srcos:workroom:professional-intelligence-demo" + }, + "participants": [ + { + "role": "user", + "participantRef": "urn:srcos:subject:user:operator", + "agentRegistryRef": null + }, + { + "role": "assistant", + "participantRef": "urn:srcos:agent:noetica", + "agentRegistryRef": "urn:srcos:agent-registry:noetica" + }, + { + "role": "provider", + "participantRef": "provider:openai", + "agentRegistryRef": null + } + ], + "task": { + "taskRef": "urn:srcos:task:noetica-standalone-0001", + "status": "success", + "modelHint": "gpt-4.1", + "modelRouted": "gpt-4.1", + "provider": "openai", + "latencyMs": 842 + }, + "steeringIntent": { + "steeringKind": "none", + "featureRef": null, + "strength": null, + "status": "noop" + }, + "governanceTrace": { + "policyAdmitted": true, + "policyRef": "noetica://standalone/local-policy", + "policyDecisionRefs": [ + "urn:srcos:decision:noetica-standalone-admit-0001" + ], + "grantRefs": [ + "call:openai" + ], + "memoryScopeRef": "noetica-session-local", + "memoryWritten": false, + "contextPackRefs": [], + "requestHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "evidenceHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "providerRouteEvidenceRef": "urn:srcos:evidence:provider-route:noetica-standalone-0001", + "agentPlaneRunRef": null, + "evidenceRefs": [ + "urn:srcos:evidence:noetica-standalone-0001" + ], + "replayRef": "urn:srcos:replay:noetica-standalone-0001" + }, + "payloadMode": "summary", + "payload": { + "summary": "Noetica completed a standalone provider call and emitted a governance trace consumable by AgentTerm.", + "egress": { + "contactedExternalProvider": true, + "sentPrompt": true, + "promptEgressDefault": "external-provider" + } + }, + "sourceEventRefs": [ + "urn:srcos:ops-history-event:noetica-local-demo-0001" + ], + "redactionRefs": [], + "integrity": { + "eventHash": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "signature": null + } +} diff --git a/tests/test_interaction.py b/tests/test_interaction.py new file mode 100644 index 0000000..88d8894 --- /dev/null +++ b/tests/test_interaction.py @@ -0,0 +1,59 @@ +"""Tests for SourceOSInteractionEvent rendering.""" + +from __future__ import annotations + +from pathlib import Path + +from agent_term.interaction import ( + interaction_to_agent_term_event, + load_interaction_event, + render_interaction_event, + validate_interaction_event, +) + + +FIXTURE = Path(__file__).parent / "fixtures" / "sourceos_interaction_event.json" + + +def test_fixture_passes_local_required_field_checks() -> None: + event = load_interaction_event(FIXTURE) + + assert validate_interaction_event(event) == [] + + +def test_render_interaction_event_exposes_governance_trace() -> None: + event = load_interaction_event(FIXTURE) + + rendered = render_interaction_event(event) + + assert "SourceOS interaction event" in rendered + assert "surface: noetica (SocioProphet/Noetica)" in rendered + assert "policy: admitted" in rendered + assert "memory: not-written" in rendered + assert "provider: openai" in rendered + assert "replay: urn:srcos:replay:noetica-standalone-0001" in rendered + + +def test_interaction_event_converts_to_agent_term_event() -> None: + event = load_interaction_event(FIXTURE) + + converted = interaction_to_agent_term_event( + event, + channel="!demo", + sender="@agent-term", + ) + + assert converted.channel == "!demo" + assert converted.sender == "@agent-term" + assert converted.kind == "sourceos_interaction" + assert converted.source == "noetica" + assert converted.thread_id == "urn:srcos:thread:noetica-local-demo" + assert converted.metadata["event_class"] == "interaction.task_completed" + assert converted.metadata["governanceTrace"]["policyAdmitted"] is True + + +def test_missing_governance_trace_fails_local_check() -> None: + event = load_interaction_event(FIXTURE) + event.pop("governanceTrace") + + assert "missing top-level field: governanceTrace" in validate_interaction_event(event)