diff --git a/src/agent_term/cli.py b/src/agent_term/cli.py index 71d9894..16b2d76 100644 --- a/src/agent_term/cli.py +++ b/src/agent_term/cli.py @@ -10,7 +10,28 @@ from typing import Any from agent_term.events import AgentTermEvent +from agent_term.graph_binding import ( + format_agent_list, + format_graph_snapshot, + format_leases, + get_agent_list, + get_capability_leases, + get_graph_snapshot, +) +from agent_term.ops_history import context_pack_plan, policy_explain from agent_term.planes import get_plane, iter_planes +from agent_term.reasoning_run import ( + format_run_index, + format_run_trace, + get_run_index, + get_run_trace, +) +from agent_term.state_integrity import ( + format_conflict_queue, + format_status, + get_conflict_queue, + get_status, +) from agent_term.store import DEFAULT_DB_PATH, EventStore @@ -90,6 +111,64 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("shell", help="Open the minimal AgentTerm interactive shell.") + # --- state-integrity (#36) --- + si = subparsers.add_parser( + "state-integrity", + help="SourceOS State Integrity ChatOps surface (sourceos-syncd).", + ) + si_sub = si.add_subparsers(dest="si_command", required=True) + si_status = si_sub.add_parser("status", help="Show State Integrity daemon status.") + si_status.add_argument("--json", dest="json_mode", action="store_true") + si_conflicts = si_sub.add_parser("conflicts", help="List pending state conflicts.") + si_conflicts.add_argument("--json", dest="json_mode", action="store_true") + si_conflicts.add_argument("--limit", type=int, default=20) + + # --- graph (#37) --- + graph = subparsers.add_parser( + "graph", + help="SourceOS agentic graph: Agent Registry, Policy Fabric, capability leases.", + ) + graph_sub = graph.add_subparsers(dest="graph_command", required=True) + graph_status = graph_sub.add_parser("status", help="Show graph foundation snapshot.") + graph_status.add_argument("--json", dest="json_mode", action="store_true") + graph_agents = graph_sub.add_parser("agents", help="List registered agents.") + graph_agents.add_argument("--json", dest="json_mode", action="store_true") + graph_leases = graph_sub.add_parser("leases", help="Show active capability leases.") + graph_leases.add_argument("--agent", dest="agent_ref", default=None) + graph_leases.add_argument("--json", dest="json_mode", action="store_true") + + # --- reasoning-run (#38) --- + rr = subparsers.add_parser( + "reasoning-run", + help="Superconscious ReasoningRun trace console.", + ) + rr_sub = rr.add_subparsers(dest="rr_command", required=True) + rr_list = rr_sub.add_parser("list", help="List recent ReasoningRun traces.") + rr_list.add_argument("--limit", type=int, default=10) + rr_list.add_argument("--agent", dest="agent_ref", default=None) + rr_list.add_argument("--json", dest="json_mode", action="store_true") + rr_show = rr_sub.add_parser("show", help="Show a single ReasoningRun trace.") + rr_show.add_argument("run_ref") + rr_show.add_argument("--json", dest="json_mode", action="store_true") + + # --- ops-history (#33) --- + oh = subparsers.add_parser( + "ops-history", + help="OpsHistory control plane: sync policy, context packs, replay.", + ) + oh_sub = oh.add_subparsers(dest="oh_command", required=True) + oh_policy = oh_sub.add_parser("policy", help="Explain a sync policy profile.") + oh_policy.add_argument( + "--profile", + default="active-multi-agent-room", + help="Policy profile name (default: active-multi-agent-room).", + ) + oh_policy.add_argument("--json", dest="json_mode", action="store_true") + oh_cp = oh_sub.add_parser("context-pack", help="Plan a context-pack export.") + oh_cp.add_argument("--workroom", default="pi-demo") + oh_cp.add_argument("--topic", default=None) + oh_cp.add_argument("--json", dest="json_mode", action="store_true") + return parser @@ -416,6 +495,73 @@ def cmd_shell(store: EventStore) -> int: append_and_print(store, event) +def cmd_state_integrity(args: argparse.Namespace) -> int: + """State Integrity ChatOps surface (issue #36).""" + sub = args.si_command + json_mode = getattr(args, "json_mode", False) + if sub == "status": + status = get_status() + print(format_status(status, json_mode=json_mode)) + return 0 + if sub == "conflicts": + queue = get_conflict_queue(limit=args.limit) + print(format_conflict_queue(queue, json_mode=json_mode)) + return 0 + raise SystemExit(f"unknown state-integrity command: {sub}") + + +def cmd_graph(args: argparse.Namespace) -> int: + """Agentic graph ChatOps surface (issue #37).""" + sub = args.graph_command + json_mode = getattr(args, "json_mode", False) + if sub == "status": + snapshot = get_graph_snapshot() + print(format_graph_snapshot(snapshot, json_mode=json_mode)) + return 0 + if sub == "agents": + agents = get_agent_list() + print(format_agent_list(agents, json_mode=json_mode)) + return 0 + if sub == "leases": + leases = get_capability_leases(agent_ref=getattr(args, "agent_ref", None)) + print(format_leases(leases, json_mode=json_mode)) + return 0 + raise SystemExit(f"unknown graph command: {sub}") + + +def cmd_reasoning_run(args: argparse.Namespace) -> int: + """ReasoningRun trace console (issue #38).""" + sub = args.rr_command + json_mode = getattr(args, "json_mode", False) + if sub == "list": + index = get_run_index(limit=args.limit, agent_ref=getattr(args, "agent_ref", None)) + print(format_run_index(index, json_mode=json_mode)) + return 0 + if sub == "show": + trace = get_run_trace(args.run_ref) + if trace is None: + print(f"No ReasoningRun found: {args.run_ref}") + return 1 + print(format_run_trace(trace, json_mode=json_mode)) + return 0 + raise SystemExit(f"unknown reasoning-run command: {sub}") + + +def cmd_ops_history(args: argparse.Namespace) -> int: + """OpsHistory control plane (issue #33).""" + sub = args.oh_command + json_mode = getattr(args, "json_mode", False) + if sub == "policy": + result = policy_explain(args.profile) + print(json.dumps(result, indent=2)) + return 0 + if sub == "context-pack": + result = context_pack_plan(args.workroom, topic=args.topic) + print(json.dumps(result, indent=2)) + return 0 + raise SystemExit(f"unknown ops-history command: {sub}") + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) @@ -440,6 +586,14 @@ def main(argv: list[str] | None = None) -> int: return cmd_sherlock_packet(store, args) if args.command == "shell": return cmd_shell(store) + if args.command == "state-integrity": + return cmd_state_integrity(args) + if args.command == "graph": + return cmd_graph(args) + if args.command == "reasoning-run": + return cmd_reasoning_run(args) + if args.command == "ops-history": + return cmd_ops_history(args) finally: store.close() diff --git a/src/agent_term/graph_binding.py b/src/agent_term/graph_binding.py new file mode 100644 index 0000000..7350298 --- /dev/null +++ b/src/agent_term/graph_binding.py @@ -0,0 +1,162 @@ +"""Terminal-native graph, policy, and capability lease ChatOps for AgentTerm. + +Provides operator commands for querying the SourceOS agentic graph foundation +(Agent Registry, Policy Fabric, Memory Mesh, SourceChannel) without bypassing +governed lanes. All queries are dry-run stubs until the live services are +reachable. Implements issue agent-term#37. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +STUB_NOTE = "Live graph services not yet reachable — stub response per agent-term#37" + + +def _now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +@dataclass(frozen=True) +class AgentRegistryEntry: + """An agent registered in the SourceOS Agent Registry.""" + + agent_ref: str + name: str + kind: str + status: str + capability_contract_ref: str | None + policy_profile_ref: str | None + registered_at: str + + +@dataclass(frozen=True) +class PolicyFabricDecision: + """A policy decision record from the Policy Fabric.""" + + decision_ref: str + subject_ref: str + action: str + decision: str + policy_ref: str + decided_at: str + note: str = STUB_NOTE + + +@dataclass(frozen=True) +class CapabilityLease: + """A capability lease granted to an agent.""" + + lease_ref: str + agent_ref: str + capability: str + grant_source: str + decision: str + granted_at: str + expires_at: str | None + note: str = STUB_NOTE + + +@dataclass +class GraphSnapshot: + """Snapshot of the local agentic graph state visible to AgentTerm.""" + + agents: list[AgentRegistryEntry] = field(default_factory=list) + active_leases: list[CapabilityLease] = field(default_factory=list) + recent_decisions: list[PolicyFabricDecision] = field(default_factory=list) + memory_mesh_connected: bool = False + source_channel_connected: bool = False + note: str = STUB_NOTE + + def to_dict(self) -> dict[str, Any]: + return { + "agents": [ + { + "agent_ref": a.agent_ref, + "name": a.name, + "kind": a.kind, + "status": a.status, + } + for a in self.agents + ], + "active_leases": [ + { + "lease_ref": l.lease_ref, + "agent_ref": l.agent_ref, + "capability": l.capability, + "decision": l.decision, + } + for l in self.active_leases + ], + "recent_decisions_count": len(self.recent_decisions), + "memory_mesh_connected": self.memory_mesh_connected, + "source_channel_connected": self.source_channel_connected, + "note": self.note, + } + + +def get_graph_snapshot() -> GraphSnapshot: + """Return current graph state. Stub until live services are reachable.""" + return GraphSnapshot() + + +def get_agent_list() -> list[AgentRegistryEntry]: + """Return registered agents from Agent Registry. Stub.""" + return [] + + +def get_capability_leases(agent_ref: str | None = None) -> list[CapabilityLease]: + """Return active capability leases, optionally filtered by agent. Stub.""" + return [] + + +def get_policy_decisions(limit: int = 10) -> list[PolicyFabricDecision]: + """Return recent policy decisions from Policy Fabric. Stub.""" + return [] + + +def format_graph_snapshot(snapshot: GraphSnapshot, json_mode: bool = False) -> str: + if json_mode: + return json.dumps(snapshot.to_dict(), indent=2) + lines = [ + "SourceOS Agentic Graph", + f" agents: {len(snapshot.agents)}", + f" active_leases: {len(snapshot.active_leases)}", + f" recent_decisions: {len(snapshot.recent_decisions)}", + f" memory_mesh_connected: {snapshot.memory_mesh_connected}", + f" source_channel_connected:{snapshot.source_channel_connected}", + f" note: {snapshot.note}", + ] + return "\n".join(lines) + + +def format_agent_list(agents: list[AgentRegistryEntry], json_mode: bool = False) -> str: + if json_mode: + return json.dumps( + [{"agent_ref": a.agent_ref, "name": a.name, "kind": a.kind, "status": a.status} for a in agents], + indent=2, + ) + if not agents: + return f"No agents registered. (note: {STUB_NOTE})" + lines = ["Registered agents:"] + for a in agents: + lines.append(f" {a.name} [{a.kind}] {a.status} {a.agent_ref}") + return "\n".join(lines) + + +def format_leases(leases: list[CapabilityLease], json_mode: bool = False) -> str: + if json_mode: + return json.dumps( + [{"lease_ref": l.lease_ref, "capability": l.capability, "decision": l.decision} for l in leases], + indent=2, + ) + if not leases: + return f"No active capability leases. (note: {STUB_NOTE})" + lines = ["Active capability leases:"] + for l in leases: + lines.append(f" {l.capability} {l.decision} {l.lease_ref}") + return "\n".join(lines) diff --git a/src/agent_term/reasoning_run.py b/src/agent_term/reasoning_run.py new file mode 100644 index 0000000..fa25fe5 --- /dev/null +++ b/src/agent_term/reasoning_run.py @@ -0,0 +1,146 @@ +"""Superconscious ReasoningRun trace rendering for AgentTerm. + +Provides terminal operator views of governed recursive reasoning artifacts +emitted by Superconscious and promoted into SourceOS-Linux/sourceos-spec. +All queries are dry-run stubs until the live ReasoningRun evidence store +is reachable. Implements issue agent-term#38. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +STUB_NOTE = "ReasoningRun evidence store not yet reachable — stub response per agent-term#38" + + +def _now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +@dataclass(frozen=True) +class ReasoningStep: + """A single step in a recursive reasoning trace.""" + + step_ref: str + step_index: int + step_kind: str + premise_refs: list[str] + conclusion_ref: str + confidence: float + policy_decision_ref: str | None + suppress_mutation: bool + observed_at: str + + +@dataclass(frozen=True) +class ReasoningRunTrace: + """A complete governed ReasoningRun trace artifact.""" + + run_ref: str + agent_ref: str + trigger_ref: str + goal_ref: str + steps: list[ReasoningStep] + outcome: str + confidence: float + receipt_ref: str | None + suppress_mutation: bool + started_at: str + completed_at: str | None + note: str = STUB_NOTE + + def to_dict(self) -> dict[str, Any]: + return { + "run_ref": self.run_ref, + "agent_ref": self.agent_ref, + "trigger_ref": self.trigger_ref, + "goal_ref": self.goal_ref, + "step_count": len(self.steps), + "outcome": self.outcome, + "confidence": self.confidence, + "receipt_ref": self.receipt_ref, + "suppress_mutation": self.suppress_mutation, + "started_at": self.started_at, + "completed_at": self.completed_at, + "note": self.note, + } + + +@dataclass +class ReasoningRunIndex: + """Index of recent ReasoningRun traces visible to the operator.""" + + runs: list[ReasoningRunTrace] = field(default_factory=list) + total: int = 0 + failed_count: int = 0 + suppressed_count: int = 0 + note: str = STUB_NOTE + + +def get_run_index(limit: int = 10, agent_ref: str | None = None) -> ReasoningRunIndex: + """Return recent ReasoningRun traces. Stub until evidence store is live.""" + return ReasoningRunIndex() + + +def get_run_trace(run_ref: str) -> ReasoningRunTrace | None: + """Return a single ReasoningRun trace by ref. Stub.""" + return None + + +def format_run_index(index: ReasoningRunIndex, json_mode: bool = False) -> str: + if json_mode: + return json.dumps( + { + "total": index.total, + "failed_count": index.failed_count, + "suppressed_count": index.suppressed_count, + "runs": [r.to_dict() for r in index.runs], + "note": index.note, + }, + indent=2, + ) + if not index.runs: + return f"No ReasoningRun traces found. (note: {index.note})" + lines = [f"ReasoningRun traces ({index.total} total, {index.failed_count} failed):"] + for r in index.runs: + status = r.outcome + lines.append(f" {r.run_ref} [{status}] confidence={r.confidence:.2f} steps={len(r.steps)}") + return "\n".join(lines) + + +def format_run_trace(trace: ReasoningRunTrace, json_mode: bool = False) -> str: + if json_mode: + return json.dumps( + { + **trace.to_dict(), + "steps": [ + { + "step_index": s.step_index, + "step_kind": s.step_kind, + "confidence": s.confidence, + "suppress_mutation": s.suppress_mutation, + } + for s in trace.steps + ], + }, + indent=2, + ) + lines = [ + f"ReasoningRun: {trace.run_ref}", + f" agent: {trace.agent_ref}", + f" outcome: {trace.outcome}", + f" confidence: {trace.confidence:.2f}", + f" steps: {len(trace.steps)}", + f" suppress_mutation:{trace.suppress_mutation}", + f" started: {trace.started_at}", + f" completed: {trace.completed_at or 'in-progress'}", + ] + if trace.steps: + lines.append(" Steps:") + for s in trace.steps: + lines.append(f" [{s.step_index}] {s.step_kind} confidence={s.confidence:.2f} suppress={s.suppress_mutation}") + return "\n".join(lines) diff --git a/src/agent_term/state_integrity.py b/src/agent_term/state_integrity.py new file mode 100644 index 0000000..67d4d36 --- /dev/null +++ b/src/agent_term/state_integrity.py @@ -0,0 +1,189 @@ +"""SourceOS State Integrity ChatOps surface for AgentTerm. + +Exposes state integrity events, conflict queues, and repair approvals as +terminal-native operator interactions. All queries are dry-run stubs until +sourceos-syncd is available at runtime. Implements issue agent-term#36. +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +STUB_NOTE = "sourceos-syncd not yet running — stub response per agent-term#36" + + +def _now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _syncd_available() -> bool: + try: + result = subprocess.run( + ["sourceos-syncd", "status", "--json"], + capture_output=True, + timeout=2, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +@dataclass(frozen=True) +class StateIntegrityStatus: + """State integrity daemon status snapshot.""" + + daemon_available: bool + daemon_state: str + active_profile: str + active_workspace: str + active_device: str + degraded: bool + policy_blocked: bool + conflict_count: int + repair_needed: bool + note: str = STUB_NOTE + + def to_dict(self) -> dict[str, Any]: + return { + "daemon_available": self.daemon_available, + "daemon_state": self.daemon_state, + "active_profile": self.active_profile, + "active_workspace": self.active_workspace, + "active_device": self.active_device, + "degraded": self.degraded, + "policy_blocked": self.policy_blocked, + "conflict_count": self.conflict_count, + "repair_needed": self.repair_needed, + "note": self.note, + } + + +@dataclass(frozen=True) +class ConflictRecord: + """A single state conflict awaiting operator review.""" + + conflict_ref: str + object_ref: str + staleness_class: str + local_value_redacted: bool + remote_source_ref: str + auto_repair_eligible: bool + human_review_required: bool + observed_at: str + + +@dataclass +class ConflictQueue: + """Ordered queue of state conflicts for operator review.""" + + conflicts: list[ConflictRecord] = field(default_factory=list) + total: int = 0 + blocked_count: int = 0 + note: str = STUB_NOTE + + +@dataclass(frozen=True) +class RepairApproval: + """Operator approval record for a state repair action.""" + + approval_ref: str + conflict_ref: str + object_ref: str + repair_action: str + approved_by: str + policy_decision_ref: str + approved_at: str + note: str = STUB_NOTE + + +def get_status() -> StateIntegrityStatus: + """Return current state integrity status from syncd or stub.""" + available = _syncd_available() + return StateIntegrityStatus( + daemon_available=available, + daemon_state="running" if available else "unavailable", + active_profile="unknown", + active_workspace="unknown", + active_device="unknown", + degraded=False, + policy_blocked=False, + conflict_count=0, + repair_needed=False, + ) + + +def get_conflict_queue(limit: int = 20) -> ConflictQueue: + """Return pending conflicts from syncd or stub.""" + return ConflictQueue( + conflicts=[], + total=0, + blocked_count=0, + ) + + +def approve_repair( + conflict_ref: str, + actor_ref: str, + policy_decision_ref: str, +) -> RepairApproval: + """Record operator approval of a state repair. Dry-run until syncd is live.""" + return RepairApproval( + approval_ref=f"urn:srcos:repair-approval:{conflict_ref}:{_now()}", + conflict_ref=conflict_ref, + object_ref="unknown", + repair_action="auto-repair", + approved_by=actor_ref, + policy_decision_ref=policy_decision_ref, + approved_at=_now(), + ) + + +def format_status(status: StateIntegrityStatus, json_mode: bool = False) -> str: + if json_mode: + return json.dumps(status.to_dict(), indent=2) + lines = [ + "SourceOS State Integrity", + f" daemon: {status.daemon_state} ({'available' if status.daemon_available else 'unavailable'})", + f" profile: {status.active_profile}", + f" workspace: {status.active_workspace}", + f" device: {status.active_device}", + f" degraded: {status.degraded}", + f" policy_blocked: {status.policy_blocked}", + f" conflict_count: {status.conflict_count}", + f" repair_needed: {status.repair_needed}", + f" note: {status.note}", + ] + return "\n".join(lines) + + +def format_conflict_queue(queue: ConflictQueue, json_mode: bool = False) -> str: + if json_mode: + return json.dumps( + { + "total": queue.total, + "blocked_count": queue.blocked_count, + "conflicts": [ + { + "conflict_ref": c.conflict_ref, + "object_ref": c.object_ref, + "staleness_class": c.staleness_class, + "auto_repair_eligible": c.auto_repair_eligible, + "human_review_required": c.human_review_required, + } + for c in queue.conflicts + ], + "note": queue.note, + }, + indent=2, + ) + if not queue.conflicts: + return f"No conflicts pending (note: {queue.note})" + lines = [f"Conflict queue ({queue.total} total, {queue.blocked_count} blocked):"] + for c in queue.conflicts: + lines.append(f" {c.conflict_ref} [{c.staleness_class}] review={'required' if c.human_review_required else 'optional'}") + return "\n".join(lines)