agent-operating-substrate 0.1.0__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.
aos/curator.py ADDED
@@ -0,0 +1,143 @@
1
+ """Evolutionary rule curator for pruning, subsumption, and substrate hygiene."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import yaml
9
+
10
+ from aos.blackboard import post_event
11
+ from aos.discovery import discover_rules
12
+ from aos.models import InvariantRule, Scope
13
+ from aos.validator import validate_rule_dict
14
+
15
+
16
+ @dataclass
17
+ class CurationAction:
18
+ rule_id: str
19
+ action_type: str
20
+ reason: str
21
+ superseded_by: Optional[str] = None
22
+
23
+
24
+ def is_path_subsumed(sub_pattern: str, super_pattern: str) -> bool:
25
+ """Check if super_pattern universally covers sub_pattern."""
26
+ if super_pattern in ("**/*", "**", "*"):
27
+ return True
28
+ if sub_pattern == super_pattern:
29
+ return True
30
+ if super_pattern.endswith("/**") and sub_pattern.startswith(super_pattern[:-3]):
31
+ return True
32
+ return False
33
+
34
+
35
+ def is_scope_subsumed(sub_scope: Scope, super_scope: Scope) -> bool:
36
+ """Determine whether sub_scope is a strict subset of super_scope."""
37
+ # Language coverage check
38
+ if super_scope.languages:
39
+ if not sub_scope.languages:
40
+ return False
41
+ super_langs = {l.lower() for l in super_scope.languages}
42
+ sub_langs = {l.lower() for l in sub_scope.languages}
43
+ if not sub_langs.issubset(super_langs):
44
+ return False
45
+
46
+ # Path coverage check
47
+ if super_scope.paths:
48
+ if not sub_scope.paths:
49
+ return False
50
+ for sub_p in sub_scope.paths:
51
+ covered = any(is_path_subsumed(sub_p, sup_p) for sup_p in super_scope.paths)
52
+ if not covered:
53
+ return False
54
+
55
+ return True
56
+
57
+
58
+ def archive_rule(
59
+ rule: InvariantRule,
60
+ reason: str,
61
+ substrate_dir: Path | str = ".agents/substrate",
62
+ event_log_path: Optional[Path | str] = None,
63
+ ) -> Path:
64
+ """Move a rule to archive status and log a blackboard event."""
65
+ sub_path = Path(substrate_dir)
66
+ if (sub_path / ".agents" / "substrate").is_dir():
67
+ sub_path = sub_path / ".agents" / "substrate"
68
+
69
+ archive_dir = sub_path / "archive"
70
+ archive_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+ rule_dict = rule.to_dict()
73
+ rule_dict["status"] = "archive"
74
+ if "provenance" not in rule_dict:
75
+ rule_dict["provenance"] = {}
76
+ rule_dict["provenance"]["last_verified_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
77
+
78
+ target_file = archive_dir / f"{rule.id}.yaml"
79
+ with open(target_file, "w", encoding="utf-8") as f:
80
+ yaml.safe_dump(rule_dict, f, sort_keys=False)
81
+
82
+ if rule.file_path and rule.file_path.is_file():
83
+ rule.file_path.unlink()
84
+
85
+ rule.file_path = target_file
86
+ rule.status = "archive"
87
+
88
+ post_event(
89
+ {
90
+ "type": "RULE_ARCHIVED",
91
+ "sender": "evolutionary-curator-v1",
92
+ "payload": {
93
+ "rule_id": rule.id,
94
+ "reason": reason,
95
+ },
96
+ },
97
+ event_log_path=event_log_path,
98
+ )
99
+
100
+ return target_file
101
+
102
+
103
+ def curate_substrate(
104
+ substrate_dir: Path | str = ".agents/substrate",
105
+ dry_run: bool = False,
106
+ event_log_path: Optional[Path | str] = None,
107
+ ) -> list[CurationAction]:
108
+ """Inspect active rules for duplicate or subsumed invariants and prune them."""
109
+ rules, _ = discover_rules(substrate_dir, statuses={"active"})
110
+ actions: list[CurationAction] = []
111
+
112
+ # Sort rules deterministically by id
113
+ rules = sorted(rules, key=lambda r: r.id)
114
+
115
+ # Detect subsumption: Rule B has identical statement but narrower scope than Rule A
116
+ archived_ids: set[str] = set()
117
+ for i, r_super in enumerate(rules):
118
+ if r_super.id in archived_ids:
119
+ continue
120
+ for j, r_sub in enumerate(rules):
121
+ if i == j or r_sub.id in archived_ids:
122
+ continue
123
+ # Compare statements
124
+ same_statement = r_super.invariant.statement.strip().lower() == r_sub.invariant.statement.strip().lower()
125
+ if same_statement and is_scope_subsumed(r_sub.scope, r_super.scope):
126
+ reason = f"Subsumed by broader rule '{r_super.id}' with identical invariant statement."
127
+ action = CurationAction(
128
+ rule_id=r_sub.id,
129
+ action_type="ARCHIVE_SUBSUMED",
130
+ reason=reason,
131
+ superseded_by=r_super.id,
132
+ )
133
+ actions.append(action)
134
+ archived_ids.add(r_sub.id)
135
+ if not dry_run:
136
+ archive_rule(
137
+ r_sub,
138
+ reason=reason,
139
+ substrate_dir=substrate_dir,
140
+ event_log_path=event_log_path,
141
+ )
142
+
143
+ return actions
aos/daemon.py ADDED
@@ -0,0 +1,121 @@
1
+ """Autonomous background daemon for reactive auditing, event interception, and curation."""
2
+
3
+ from __future__ import annotations
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from aos.blackboard import BlackboardEvent, get_default_event_log, post_event, read_events
10
+ from aos.curator import curate_substrate
11
+ from aos.engine import RuleEngine
12
+
13
+
14
+ @dataclass
15
+ class DaemonState:
16
+ processed_event_ids: set[str] = field(default_factory=set)
17
+ injected_intent_ids: set[str] = field(default_factory=set)
18
+ curation_interval_seconds: float = 300.0
19
+ last_curation_timestamp: float = 0.0
20
+
21
+
22
+ class SubstrateDaemon:
23
+ """Continuous background engine that audits announced intents and curates rules."""
24
+
25
+ def __init__(
26
+ self,
27
+ root_dir: Path | str = ".",
28
+ event_log_path: Optional[Path | str] = None,
29
+ poll_interval: float = 2.0,
30
+ curation_interval: float = 60.0,
31
+ ) -> None:
32
+ self.root_dir = Path(root_dir)
33
+ self.event_log_path = Path(event_log_path) if event_log_path else get_default_event_log(self.root_dir)
34
+ self.poll_interval = poll_interval
35
+ self.state = DaemonState(curation_interval_seconds=curation_interval)
36
+ self.engine = RuleEngine(root_dir=self.root_dir)
37
+
38
+ def process_event(self, event: BlackboardEvent) -> Optional[BlackboardEvent]:
39
+ """Inspect a single event and generate autonomous reactions if required."""
40
+ if event.event_id in self.state.processed_event_ids:
41
+ return None
42
+
43
+ self.state.processed_event_ids.add(event.event_id)
44
+
45
+ # Reactive Auditor: intercept INTENT_ANNOUNCEMENT
46
+ if event.type == "INTENT_ANNOUNCEMENT":
47
+ payload = event.payload
48
+ intent_id = payload.get("intent_id", "")
49
+ target_files = payload.get("target_files", [])
50
+
51
+ if intent_id and intent_id not in self.state.injected_intent_ids and target_files:
52
+ self.engine.reload()
53
+ matched_rules_map = self.engine.match_files(target_files, status="active")
54
+
55
+ # Collect unique rules
56
+ applicable_rule_ids: list[str] = []
57
+ required_checks: list[str] = []
58
+ seen_rules: set[str] = set()
59
+
60
+ for f_path, rules in matched_rules_map.items():
61
+ for r in rules:
62
+ if r.id not in seen_rules:
63
+ seen_rules.add(r.id)
64
+ applicable_rule_ids.append(r.id)
65
+ check_desc = f"rule:{r.id} ({r.invariant.enforcement})"
66
+ if r.invariant.max_blast_radius_lines is not None:
67
+ check_desc += f", max_diff_lines:{r.invariant.max_blast_radius_lines}"
68
+ required_checks.append(check_desc)
69
+
70
+ injection_event = post_event(
71
+ {
72
+ "type": "INVARIANT_INJECTION",
73
+ "sender": "daemon-auditor-agent",
74
+ "payload": {
75
+ "intent_id": intent_id,
76
+ "applicable_rules": applicable_rule_ids,
77
+ "required_checks": required_checks,
78
+ "target_files": target_files,
79
+ },
80
+ },
81
+ event_log_path=self.event_log_path,
82
+ )
83
+
84
+ self.state.injected_intent_ids.add(intent_id)
85
+ self.state.processed_event_ids.add(injection_event.event_id)
86
+ return injection_event
87
+
88
+ return None
89
+
90
+ def tick(self) -> list[BlackboardEvent]:
91
+ """Execute a single polling step: process new events and check curation schedule."""
92
+ generated: list[BlackboardEvent] = []
93
+
94
+ # 1. Read existing events and process unprocessed ones
95
+ events = read_events(event_log_path=self.event_log_path)
96
+ for ev in events:
97
+ reaction = self.process_event(ev)
98
+ if reaction is not None:
99
+ generated.append(reaction)
100
+
101
+ # 2. Check periodic curation tick
102
+ now = time.time()
103
+ if (now - self.state.last_curation_timestamp) >= self.state.curation_interval_seconds:
104
+ self.state.last_curation_timestamp = now
105
+ curate_substrate(
106
+ substrate_dir=self.root_dir / ".agents" / "substrate",
107
+ event_log_path=self.event_log_path,
108
+ )
109
+
110
+ return generated
111
+
112
+ def run(self, max_ticks: Optional[int] = None) -> None:
113
+ """Run the daemon loop until interrupted or max_ticks reached."""
114
+ ticks_done = 0
115
+ try:
116
+ while max_ticks is None or ticks_done < max_ticks:
117
+ self.tick()
118
+ ticks_done += 1
119
+ time.sleep(self.poll_interval)
120
+ except KeyboardInterrupt:
121
+ pass
aos/discovery.py ADDED
@@ -0,0 +1,72 @@
1
+ """Rule discovery and loading from the substrate filesystem."""
2
+
3
+ from __future__ import annotations
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ import yaml
7
+
8
+ from aos.models import InvariantRule
9
+ from aos.validator import validate_rule_dict
10
+
11
+
12
+ def load_rule_file(file_path: Path) -> tuple[Optional[InvariantRule], list[str]]:
13
+ """Load, parse, and validate a single rule file from disk."""
14
+ if not file_path.is_file():
15
+ return None, [f"File not found: {file_path}"]
16
+
17
+ try:
18
+ content = file_path.read_text(encoding="utf-8")
19
+ data = yaml.safe_load(content)
20
+ except Exception as exc:
21
+ return None, [f"Failed to parse YAML in {file_path}: {exc}"]
22
+
23
+ if not isinstance(data, dict):
24
+ return None, [f"Expected mapping in {file_path}, got {type(data).__name__}"]
25
+
26
+ is_valid, errors = validate_rule_dict(data)
27
+ if not is_valid:
28
+ prefixed = [f"Validation failure in {file_path}: {err}" for err in errors]
29
+ return None, prefixed
30
+
31
+ rule = InvariantRule.from_dict(data, file_path=file_path)
32
+ return rule, []
33
+
34
+
35
+ def discover_rules(
36
+ substrate_dir: Path | str,
37
+ statuses: Optional[set[str]] = None,
38
+ ) -> tuple[list[InvariantRule], list[str]]:
39
+ """Discover all invariant rules in the substrate directory tree.
40
+
41
+ Args:
42
+ substrate_dir: Path to .agents/substrate/ or repository root.
43
+ statuses: Optional filter for rule status (e.g. {'active'}).
44
+
45
+ Returns:
46
+ tuple: (list of valid InvariantRule instances, list of error messages)
47
+ """
48
+ base = Path(substrate_dir)
49
+ if (base / ".agents" / "substrate").is_dir():
50
+ base = base / ".agents" / "substrate"
51
+
52
+ rules: list[InvariantRule] = []
53
+ diagnostics: list[str] = []
54
+
55
+ subdirs = ["active", "candidate", "archive"]
56
+ target_dirs = [base / sub for sub in subdirs if (base / sub).is_dir()]
57
+
58
+ # If none of the subdirs exist, inspect base itself
59
+ if not target_dirs and base.is_dir():
60
+ target_dirs = [base]
61
+
62
+ for directory in target_dirs:
63
+ for ext in ("*.yaml", "*.yml"):
64
+ for file_path in sorted(directory.glob(ext)):
65
+ rule, errors = load_rule_file(file_path)
66
+ if errors:
67
+ diagnostics.extend(errors)
68
+ if rule is not None:
69
+ if statuses is None or rule.status in statuses:
70
+ rules.append(rule)
71
+
72
+ return rules, diagnostics