tflux2011 commited on
Commit
b2e0e38
·
verified ·
1 Parent(s): 8611379

Upload 7 files

Browse files
README.md CHANGED
@@ -1,3 +1,30 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contextual Engineering Patterns: Architecting Adaptable AI Agents
2
+
3
+ [![License: CC BY 4.0](https://img.shields.io/badge/License-CC%20BY%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by/4.0/)
4
+ [![Status: Open Access](https://img.shields.io/badge/Status-Open%20Access-green.svg)]()
5
+ [![Book: Published](https://img.shields.io/badge/Book-Read%20Full%20Text-blue)](https://zenodo.org) > **Reference implementations for the architectural patterns defined in the book *"Contextual Engineering: Architecting Adaptable AI Agents for the Real World"* by Tobi Lekan Adeosun.**
6
+
7
+ ## 📖 Overview
8
+
9
+ Standard AI agents are designed for the "Abundance Baseline" of Silicon Valley—perfect internet, unlimited power, and institutional trust. When deployed in the Global South, these agents fail due to the **"Agentic Gap"** between their reasoning capabilities and environmental realities.
10
+
11
+ This repository contains the **Python reference implementations** for the three core adaptation layers introduced in the book:
12
+ 1. **Infrastructure Adapter:** Handling offline states and compute scarcity.
13
+ 2. **Cultural Adapter:** Managing semantic drift and high-context communication.
14
+ 3. **Safety Adapter:** Enforcing constitutional guardrails and Human-in-the-Loop (HITL) workflows.
15
+
16
+ ## 📂 Repository Structure
17
+
18
+ The code is organized by the "Adapter Layer" it serves, matching the chapters of the manuscript.
19
+
20
+ ```text
21
+ ├── src
22
+ │ ├── infrastructure
23
+ │ │ ├── sync_manager.py # (Chapter 3) The "Sync-Later" Architecture & Offline Queue
24
+ │ │ └── inference_router.py # (Chapter 4) The Hybrid Router (Local vs. Cloud)
25
+ │ ├── safety
26
+ │ │ ├── sentinel.py # (Chapter 9) Constitutional Safety Checks & Kill Switches
27
+ │ │ └── escalation_ladder.py # (Chapter 10) Human-in-the-Loop Risk Evaluation Logic
28
+ │ └── culture
29
+ │ └── context_injector.py # (Chapter 6) Dynamic Few-Shot Prompting logic
30
+ └── README.md
src/infrastructure/inference_router.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/infrastructure/inference_router.py
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Optional
7
+ import logging
8
+
9
+ logging.basicConfig(level=logging.INFO)
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ # Configuration Constants
14
+ COMPLEXITY_THRESHOLD = 0.3
15
+ MIN_BATTERY_PERCENT = 20
16
+ MAX_PROMPT_LENGTH = 10000
17
+
18
+
19
+ class NetworkStatus(Enum):
20
+ CONNECTED = "CONNECTED"
21
+ DISCONNECTED = "DISCONNECTED"
22
+ HIGH_LATENCY = "HIGH_LATENCY"
23
+
24
+
25
+ class RoutingDecision(Enum):
26
+ LOCAL = "LOCAL"
27
+ CLOUD = "CLOUD"
28
+ DEGRADED_LOCAL = "DEGRADED_LOCAL"
29
+
30
+
31
+ @dataclass
32
+ class InferenceResult:
33
+ response: Optional[str]
34
+ routing_decision: RoutingDecision
35
+ is_degraded: bool = False
36
+ error_message: Optional[str] = None
37
+
38
+
39
+ class ComplexityClassifier(ABC):
40
+ @abstractmethod
41
+ def predict(self, prompt: str) -> float:
42
+ """Returns complexity score between 0.0 and 1.0."""
43
+ pass
44
+
45
+
46
+ class NetworkMonitor(ABC):
47
+ @abstractmethod
48
+ def get_status(self) -> NetworkStatus:
49
+ pass
50
+
51
+
52
+ class DeviceMonitor(ABC):
53
+ @abstractmethod
54
+ def get_battery_percent(self) -> int:
55
+ pass
56
+
57
+
58
+ class LanguageModel(ABC):
59
+ @abstractmethod
60
+ def generate(self, prompt: str) -> str:
61
+ pass
62
+
63
+
64
+ class UserNotifier(ABC):
65
+ @abstractmethod
66
+ def warn_user(self, message: str) -> None:
67
+ pass
68
+
69
+
70
+ class InferenceRouter:
71
+ """Routes inference requests based on complexity and infrastructure context."""
72
+
73
+ def __init__(
74
+ self,
75
+ local_classifier: ComplexityClassifier,
76
+ network_monitor: NetworkMonitor,
77
+ device_monitor: DeviceMonitor,
78
+ local_slm: LanguageModel,
79
+ cloud_llm: LanguageModel,
80
+ user_notifier: UserNotifier,
81
+ complexity_threshold: float = COMPLEXITY_THRESHOLD,
82
+ min_battery_percent: int = MIN_BATTERY_PERCENT
83
+ ):
84
+ self._local_classifier = local_classifier
85
+ self._network_monitor = network_monitor
86
+ self._device_monitor = device_monitor
87
+ self._local_slm = local_slm
88
+ self._cloud_llm = cloud_llm
89
+ self._user_notifier = user_notifier
90
+ self._complexity_threshold = complexity_threshold
91
+ self._min_battery_percent = min_battery_percent
92
+
93
+ def route(self, user_prompt: str) -> InferenceResult:
94
+ """Route inference request to appropriate model."""
95
+ # Input validation
96
+ if not user_prompt or not isinstance(user_prompt, str):
97
+ logger.error("Invalid user prompt provided")
98
+ return InferenceResult(
99
+ response=None,
100
+ routing_decision=RoutingDecision.LOCAL,
101
+ error_message="Invalid prompt: must be a non-empty string"
102
+ )
103
+
104
+ if len(user_prompt) > MAX_PROMPT_LENGTH:
105
+ logger.error(
106
+ f"Prompt exceeds maximum length of {MAX_PROMPT_LENGTH}")
107
+ return InferenceResult(
108
+ response=None,
109
+ routing_decision=RoutingDecision.LOCAL,
110
+ error_message=f"Prompt too long: max {MAX_PROMPT_LENGTH} characters"
111
+ )
112
+
113
+ # Step 1: Analyze Complexity locally
114
+ try:
115
+ complexity_score = self._local_classifier.predict(user_prompt)
116
+ except Exception as e:
117
+ logger.exception("Complexity classification failed")
118
+ # Default to local model on classification failure
119
+ return self._execute_local(user_prompt, is_degraded=True)
120
+
121
+ # Step 2: Check Infrastructure Context
122
+ network_status = self._network_monitor.get_status()
123
+ battery_percent = self._device_monitor.get_battery_percent()
124
+
125
+ is_offline = network_status == NetworkStatus.DISCONNECTED
126
+ battery_low = battery_percent < self._min_battery_percent
127
+
128
+ # ROUTING DECISION MATRIX
129
+
130
+ # Scenario A: Simple Task (e.g., "Set an alarm")
131
+ if complexity_score < self._complexity_threshold:
132
+ logger.info(f"Simple task (score={complexity_score:.2f}) -> LOCAL")
133
+ return self._execute_local(user_prompt)
134
+
135
+ # Scenario B: Hard Task, but No Internet or Low Battery
136
+ if is_offline or battery_low:
137
+ reason = "offline" if is_offline else "low battery"
138
+ logger.warning(f"Complex task but {reason} -> DEGRADED_LOCAL")
139
+ self._user_notifier.warn_user("Limited capability mode")
140
+ return self._execute_local(user_prompt, is_degraded=True)
141
+
142
+ # Scenario C: Hard Task + Good Internet + Battery OK
143
+ logger.info(f"Complex task (score={complexity_score:.2f}) -> CLOUD")
144
+ return self._execute_cloud(user_prompt)
145
+
146
+ def _execute_local(self, prompt: str, is_degraded: bool = False) -> InferenceResult:
147
+ """Execute inference using local small language model."""
148
+ try:
149
+ response = self._local_slm.generate(prompt)
150
+ return InferenceResult(
151
+ response=response,
152
+ routing_decision=(
153
+ RoutingDecision.DEGRADED_LOCAL if is_degraded
154
+ else RoutingDecision.LOCAL
155
+ ),
156
+ is_degraded=is_degraded
157
+ )
158
+ except Exception as e:
159
+ logger.exception("Local model inference failed")
160
+ return InferenceResult(
161
+ response=None,
162
+ routing_decision=RoutingDecision.LOCAL,
163
+ is_degraded=is_degraded,
164
+ error_message=f"Local inference failed: {str(e)}"
165
+ )
166
+
167
+ def _execute_cloud(self, prompt: str) -> InferenceResult:
168
+ """Execute inference using cloud LLM with fallback to local."""
169
+ try:
170
+ response = self._cloud_llm.generate(prompt)
171
+ return InferenceResult(
172
+ response=response,
173
+ routing_decision=RoutingDecision.CLOUD
174
+ )
175
+ except Exception as e:
176
+ logger.exception("Cloud inference failed, falling back to local")
177
+ self._user_notifier.warn_user(
178
+ "Cloud service unavailable, using limited capability mode"
179
+ )
180
+ return self._execute_local(prompt, is_degraded=True)
src/infrastructure/offline_action.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ------------------------------------------------------------------------------
2
+ # Contextual Engineering Patterns
3
+ # Copyright (c) 2025 Tobi Lekan Adeosun
4
+ # Licensed under the MIT License.
5
+ #
6
+ # From Chapter 3: "The Disconnected Agent"
7
+ # ------------------------------------------------------------------------------
8
+
9
+ import uuid
10
+ import time
11
+ import json
12
+ import hashlib
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any, Dict, Optional
16
+ import logging
17
+
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # Configuration Constants
23
+ DEFAULT_TTL_HOURS = 24
24
+ DEFAULT_MIN_BATTERY_LEVEL = 15
25
+ SECONDS_PER_HOUR = 3600
26
+
27
+
28
+ class ActionStatus(Enum):
29
+ PENDING = "PENDING"
30
+ SYNCED = "SYNCED"
31
+ FAILED = "FAILED"
32
+ EXPIRED = "EXPIRED"
33
+
34
+
35
+ def generate_hash(data: str) -> str:
36
+ """Helper to create a deterministic hash for idempotency."""
37
+ return hashlib.sha256(data.encode('utf-8')).hexdigest()
38
+
39
+
40
+ @dataclass
41
+ class OfflineAction:
42
+ """Represents an action to be synced when connectivity is restored."""
43
+
44
+ action_type: str
45
+ payload: Dict[str, Any]
46
+ priority: int = 1
47
+ ttl_hours: int = DEFAULT_TTL_HOURS
48
+ min_battery_level: int = DEFAULT_MIN_BATTERY_LEVEL
49
+
50
+ # Auto-generated fields
51
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
52
+ timestamp: float = field(default_factory=time.time)
53
+ status: ActionStatus = field(default=ActionStatus.PENDING)
54
+ idempotency_key: str = field(default="", init=False)
55
+
56
+ def __post_init__(self):
57
+ """Generate idempotency key after initialization."""
58
+ # CRITICAL: The Idempotency Key ensures that if the
59
+ # network flutters and sends the request twice,
60
+ # the server only executes it once.
61
+ key_data = f"{json.dumps(self.payload, sort_keys=True)}:{self.timestamp}"
62
+ self.idempotency_key = generate_hash(key_data)
63
+
64
+ @property
65
+ def expiry_timestamp(self) -> float:
66
+ """Calculate the expiry timestamp based on TTL."""
67
+ return self.timestamp + (self.ttl_hours * SECONDS_PER_HOUR)
68
+
69
+ def is_expired(self) -> bool:
70
+ """Check if the action has exceeded its TTL."""
71
+ return time.time() > self.expiry_timestamp
72
+
73
+ def can_sync(self, current_battery_level: int) -> bool:
74
+ """Check if conditions allow syncing this action."""
75
+ if self.is_expired():
76
+ logger.warning(f"Action {self.id} has expired")
77
+ return False
78
+
79
+ if current_battery_level < self.min_battery_level:
80
+ logger.warning(
81
+ f"Battery too low ({current_battery_level}%) to sync action {self.id}"
82
+ )
83
+ return False
84
+
85
+ return True
86
+
87
+ def mark_synced(self) -> None:
88
+ """Mark the action as successfully synced."""
89
+ self.status = ActionStatus.SYNCED
90
+ logger.info(f"Action {self.id} marked as SYNCED")
91
+
92
+ def mark_failed(self, reason: Optional[str] = None) -> None:
93
+ """Mark the action as failed."""
94
+ self.status = ActionStatus.FAILED
95
+ logger.error(f"Action {self.id} marked as FAILED: {reason}")
96
+
97
+ def mark_expired(self) -> None:
98
+ """Mark the action as expired."""
99
+ self.status = ActionStatus.EXPIRED
100
+ logger.warning(f"Action {self.id} marked as EXPIRED")
101
+
102
+ def serialize(self) -> str:
103
+ """Convert to JSON for storage in local SQLite."""
104
+ data = {
105
+ "id": self.id,
106
+ "action_type": self.action_type,
107
+ "payload": self.payload,
108
+ "priority": self.priority,
109
+ "timestamp": self.timestamp,
110
+ "status": self.status.value,
111
+ "ttl_hours": self.ttl_hours,
112
+ "expiry_timestamp": self.expiry_timestamp
113
+ # Note: idempotency_key intentionally excluded from serialization
114
+ # to prevent exposure; regenerate on deserialization
115
+ }
116
+ return json.dumps(data)
117
+
118
+ @classmethod
119
+ def deserialize(cls, json_str: str) -> "OfflineAction":
120
+ """Create an OfflineAction from JSON string."""
121
+ try:
122
+ data = json.loads(json_str)
123
+ action = cls(
124
+ action_type=data["action_type"],
125
+ payload=data["payload"],
126
+ priority=data.get("priority", 1),
127
+ ttl_hours=data.get("ttl_hours", DEFAULT_TTL_HOURS)
128
+ )
129
+ action.id = data["id"]
130
+ action.timestamp = data["timestamp"]
131
+ action.status = ActionStatus(data.get("status", "PENDING"))
132
+ return action
133
+ except (json.JSONDecodeError, KeyError) as e:
134
+ logger.exception("Failed to deserialize OfflineAction")
135
+ raise ValueError(f"Invalid JSON data for OfflineAction: {str(e)}")
src/infrastructure/sync_manager.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/infrastructure/sync_manager.py
2
+
3
+ import time
4
+ import uuid
5
+ import hashlib
6
+ import json
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import Any, Dict, List, Optional
11
+ import logging
12
+
13
+ logging.basicConfig(level=logging.INFO)
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # Configuration Constants
18
+ DEFAULT_TTL_HOURS = 24
19
+ SECONDS_PER_HOUR = 3600
20
+ MAX_RETRY_ATTEMPTS = 5
21
+ BASE_RETRY_DELAY_SECONDS = 1
22
+
23
+
24
+ class NetworkState(Enum):
25
+ ONLINE = "ONLINE"
26
+ OFFLINE = "OFFLINE"
27
+ HIGH_LATENCY = "HIGH_LATENCY"
28
+
29
+
30
+ class ActionStatus(Enum):
31
+ PENDING = "PENDING"
32
+ SYNCED = "SYNCED"
33
+ EXPIRED = "EXPIRED"
34
+ FAILED = "FAILED"
35
+ RETRY_SCHEDULED = "RETRY_SCHEDULED"
36
+
37
+
38
+ class SyncResult(Enum):
39
+ SUCCESS = "SUCCESS"
40
+ ABORT_NO_NET = "ABORT_NO_NET"
41
+ PARTIAL_SUCCESS = "PARTIAL_SUCCESS"
42
+ ALL_FAILED = "ALL_FAILED"
43
+
44
+
45
+ @dataclass
46
+ class SyncSummary:
47
+ result: SyncResult
48
+ synced_count: int = 0
49
+ failed_count: int = 0
50
+ expired_count: int = 0
51
+ error_messages: List[str] = None
52
+
53
+ def __post_init__(self):
54
+ if self.error_messages is None:
55
+ self.error_messages = []
56
+
57
+
58
+ class LocalDatabase(ABC):
59
+ @abstractmethod
60
+ def insert(self, table: str, data: dict) -> None:
61
+ pass
62
+
63
+ @abstractmethod
64
+ def query(self, sql: str) -> List[dict]:
65
+ pass
66
+
67
+ @abstractmethod
68
+ def update(self, record_id: str, **kwargs) -> None:
69
+ pass
70
+
71
+ @abstractmethod
72
+ def get_state_snapshot(self) -> dict:
73
+ """Get current local state for conflict detection."""
74
+ pass
75
+
76
+
77
+ class ApiClient(ABC):
78
+ @abstractmethod
79
+ def post(self, endpoint: str, json: dict) -> dict:
80
+ pass
81
+
82
+
83
+ class SyncQueue:
84
+ """Manages offline action queue with sync capabilities."""
85
+
86
+ def __init__(
87
+ self,
88
+ local_db: LocalDatabase,
89
+ api_client: ApiClient,
90
+ ttl_hours: int = DEFAULT_TTL_HOURS
91
+ ):
92
+ self._db = local_db
93
+ self._api_client = api_client
94
+ self._ttl_hours = ttl_hours
95
+ self._retry_counts: Dict[str, int] = {}
96
+
97
+ def get_local_state_hash(self) -> str:
98
+ """Generate a hash of the current local state for conflict detection."""
99
+ try:
100
+ state_snapshot = self._db.get_state_snapshot()
101
+ state_json = json.dumps(state_snapshot, sort_keys=True)
102
+ return hashlib.sha256(state_json.encode('utf-8')).hexdigest()
103
+ except Exception as e:
104
+ logger.exception("Failed to generate local state hash")
105
+ return ""
106
+
107
+ def enqueue_action(
108
+ self,
109
+ action_type: str,
110
+ payload: dict,
111
+ priority: int = 1
112
+ ) -> str:
113
+ """
114
+ Stores an action locally with a Time-To-Live (TTL).
115
+ If the action acts on old data, we tag it for conflict resolution.
116
+
117
+ Returns:
118
+ The action ID for tracking.
119
+ """
120
+ if not action_type or not isinstance(action_type, str):
121
+ raise ValueError("action_type must be a non-empty string")
122
+
123
+ if not isinstance(payload, dict):
124
+ raise ValueError("payload must be a dictionary")
125
+
126
+ action_id = str(uuid.uuid4())
127
+ action_item = {
128
+ "id": action_id,
129
+ "timestamp": time.time(),
130
+ "type": action_type,
131
+ "payload": self._sanitize_payload(payload),
132
+ "priority": priority,
133
+ "status": ActionStatus.PENDING.value,
134
+ "ttl_expiry": time.time() + (self._ttl_hours * SECONDS_PER_HOUR),
135
+ "device_state_hash": self.get_local_state_hash(),
136
+ "retry_count": 0
137
+ }
138
+
139
+ # Write to SQLite immediately (Atomic Write)
140
+ try:
141
+ self._db.insert("offline_queue", action_item)
142
+ logger.info(f"Action {action_id} queued locally")
143
+ return action_id
144
+ except Exception as e:
145
+ logger.exception(f"Failed to enqueue action: {str(e)}")
146
+ raise
147
+
148
+ def _sanitize_payload(self, payload: dict) -> dict:
149
+ """Remove sensitive fields before storage/transmission."""
150
+ sensitive_keys = {'password', 'token',
151
+ 'secret', 'api_key', 'credential'}
152
+ return {
153
+ k: v for k, v in payload.items()
154
+ if k.lower() not in sensitive_keys
155
+ }
156
+
157
+ def attempt_sync(self, current_network_state: NetworkState) -> SyncSummary:
158
+ """
159
+ Only attempts sync if network is stable.
160
+
161
+ Returns:
162
+ SyncSummary with results of the sync operation.
163
+ """
164
+ if current_network_state == NetworkState.OFFLINE:
165
+ logger.info("Sync aborted: network offline")
166
+ return SyncSummary(result=SyncResult.ABORT_NO_NET)
167
+
168
+ try:
169
+ pending_actions = self._db.query(
170
+ "SELECT * FROM offline_queue WHERE status='PENDING' OR status='RETRY_SCHEDULED'"
171
+ )
172
+ except Exception as e:
173
+ logger.exception("Failed to query pending actions")
174
+ return SyncSummary(
175
+ result=SyncResult.ALL_FAILED,
176
+ error_messages=[f"Database query failed: {str(e)}"]
177
+ )
178
+
179
+ if not pending_actions:
180
+ logger.info("No pending actions to sync")
181
+ return SyncSummary(result=SyncResult.SUCCESS)
182
+
183
+ # Sort by priority (higher first) then timestamp (older first)
184
+ pending_actions.sort(
185
+ key=lambda x: (-x.get('priority', 1), x['timestamp']))
186
+
187
+ synced_count = 0
188
+ failed_count = 0
189
+ expired_count = 0
190
+ error_messages = []
191
+
192
+ for action in pending_actions:
193
+ action_id = action['id']
194
+
195
+ # Check TTL expiry
196
+ if time.time() > action['ttl_expiry']:
197
+ self._db.update(action_id, status=ActionStatus.EXPIRED.value)
198
+ expired_count += 1
199
+ logger.warning(f"Action {action_id} expired")
200
+ continue
201
+
202
+ # Attempt sync
203
+ sync_success = self._sync_action(action)
204
+ if sync_success:
205
+ synced_count += 1
206
+ else:
207
+ failed_count += 1
208
+ error_messages.append(f"Action {action_id} failed to sync")
209
+
210
+ # Determine overall result
211
+ if failed_count == 0:
212
+ result = SyncResult.SUCCESS
213
+ elif synced_count == 0:
214
+ result = SyncResult.ALL_FAILED
215
+ else:
216
+ result = SyncResult.PARTIAL_SUCCESS
217
+
218
+ return SyncSummary(
219
+ result=result,
220
+ synced_count=synced_count,
221
+ failed_count=failed_count,
222
+ expired_count=expired_count,
223
+ error_messages=error_messages
224
+ )
225
+
226
+ def _sync_action(self, action: dict) -> bool:
227
+ """Attempt to sync a single action to the cloud."""
228
+ action_id = action['id']
229
+
230
+ # Prepare payload (exclude internal fields)
231
+ sync_payload = {
232
+ "id": action_id,
233
+ "type": action['type'],
234
+ "payload": action['payload'],
235
+ "timestamp": action['timestamp'],
236
+ "device_state_hash": action.get('device_state_hash', '')
237
+ }
238
+
239
+ try:
240
+ self._api_client.post("/sync", json=sync_payload)
241
+ self._db.update(action_id, status=ActionStatus.SYNCED.value)
242
+ logger.info(f"Action {action_id} synced successfully")
243
+ return True
244
+
245
+ except TimeoutError:
246
+ self._schedule_retry(action_id)
247
+ return False
248
+
249
+ except ConnectionError as e:
250
+ logger.error(f"Connection error syncing {action_id}: {str(e)}")
251
+ self._schedule_retry(action_id)
252
+ return False
253
+
254
+ except Exception as e:
255
+ logger.exception(f"Unexpected error syncing {action_id}")
256
+ retry_count = self._retry_counts.get(action_id, 0)
257
+ if retry_count >= MAX_RETRY_ATTEMPTS:
258
+ self._db.update(action_id, status=ActionStatus.FAILED.value)
259
+ logger.error(
260
+ f"Action {action_id} permanently failed after {retry_count} retries")
261
+ else:
262
+ self._schedule_retry(action_id)
263
+ return False
264
+
265
+ def _schedule_retry(self, action_id: str) -> None:
266
+ """Schedule an action for retry with exponential backoff."""
267
+ current_count = self._retry_counts.get(action_id, 0)
268
+
269
+ if current_count >= MAX_RETRY_ATTEMPTS:
270
+ self._db.update(action_id, status=ActionStatus.FAILED.value)
271
+ logger.error(
272
+ f"Action {action_id} exceeded max retries ({MAX_RETRY_ATTEMPTS})"
273
+ )
274
+ return
275
+
276
+ self._retry_counts[action_id] = current_count + 1
277
+ delay = BASE_RETRY_DELAY_SECONDS * (2 ** current_count)
278
+
279
+ self._db.update(
280
+ action_id,
281
+ status=ActionStatus.RETRY_SCHEDULED.value,
282
+ retry_count=current_count + 1
283
+ )
284
+
285
+ logger.info(
286
+ f"Action {action_id} scheduled for retry #{current_count + 1} "
287
+ f"in {delay} seconds"
288
+ )
src/safety/escalation_ladder.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/safety/escalation_ladder.py
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any, Optional
7
+ import logging
8
+
9
+ logging.basicConfig(level=logging.INFO)
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class RiskLevel(Enum):
14
+ LOW = "LOW"
15
+ MEDIUM = "MEDIUM"
16
+ HIGH = "HIGH"
17
+ CRITICAL = "CRITICAL"
18
+
19
+
20
+ class ExecutionStatus(Enum):
21
+ SUCCESS = "SUCCESS"
22
+ WAITING_FOR_APPROVAL = "WAITING_FOR_APPROVAL"
23
+ REJECTED = "REJECTED"
24
+ ERROR = "ERROR"
25
+
26
+
27
+ @dataclass
28
+ class ExecutionResult:
29
+ status: ExecutionStatus
30
+ result: Optional[Any] = None
31
+ error_message: Optional[str] = None
32
+ decision_id: Optional[str] = None
33
+
34
+
35
+ class RiskPolicy(ABC):
36
+ @abstractmethod
37
+ def get_level(self, tool_name: str) -> RiskLevel:
38
+ pass
39
+
40
+
41
+ class Tool(ABC):
42
+ @abstractmethod
43
+ def run(self, parameters: dict) -> Any:
44
+ pass
45
+
46
+
47
+ class ApprovalService(ABC):
48
+ @abstractmethod
49
+ def create_approval_request(self, tool_name: str, parameters: dict) -> str:
50
+ pass
51
+
52
+ @abstractmethod
53
+ def notify_manager(self, decision_id: str) -> None:
54
+ pass
55
+
56
+
57
+ class EscalationLadder:
58
+ def __init__(
59
+ self,
60
+ risk_policy: RiskPolicy,
61
+ tool: Tool,
62
+ approval_service: ApprovalService
63
+ ):
64
+ self._risk_policy = risk_policy
65
+ self._tool = tool
66
+ self._approval_service = approval_service
67
+
68
+ def execute_tool(self, tool_name: str, parameters: dict) -> ExecutionResult:
69
+ """Execute a tool with appropriate risk-based escalation."""
70
+ if not tool_name or not isinstance(tool_name, str):
71
+ logger.error("Invalid tool_name provided")
72
+ return ExecutionResult(
73
+ status=ExecutionStatus.ERROR,
74
+ error_message="Invalid tool_name: must be a non-empty string"
75
+ )
76
+
77
+ if not isinstance(parameters, dict):
78
+ logger.error("Invalid parameters provided")
79
+ return ExecutionResult(
80
+ status=ExecutionStatus.ERROR,
81
+ error_message="Invalid parameters: must be a dictionary"
82
+ )
83
+
84
+ try:
85
+ risk_level = self._risk_policy.get_level(tool_name)
86
+ except Exception as e:
87
+ logger.exception("Failed to assess risk level")
88
+ return ExecutionResult(
89
+ status=ExecutionStatus.ERROR,
90
+ error_message=f"Risk assessment failed: {str(e)}"
91
+ )
92
+
93
+ if risk_level == RiskLevel.CRITICAL:
94
+ logger.warning(f"CRITICAL risk tool '{tool_name}' blocked")
95
+ return ExecutionResult(
96
+ status=ExecutionStatus.REJECTED,
97
+ error_message="Critical risk tools are not permitted"
98
+ )
99
+
100
+ if risk_level == RiskLevel.HIGH:
101
+ return self._handle_high_risk(tool_name, parameters)
102
+
103
+ if risk_level == RiskLevel.MEDIUM:
104
+ logger.info(f"MEDIUM risk tool '{tool_name}' - logging for audit")
105
+ return self._execute_with_logging(tool_name, parameters)
106
+
107
+ # LOW risk - execute immediately
108
+ return self._execute_tool(parameters)
109
+
110
+ def _handle_high_risk(self, tool_name: str, parameters: dict) -> ExecutionResult:
111
+ """Handle high-risk tool execution with approval workflow."""
112
+ try:
113
+ decision_id = self._approval_service.create_approval_request(
114
+ tool_name, parameters
115
+ )
116
+ self._approval_service.notify_manager(decision_id)
117
+ logger.info(f"Approval request created: {decision_id}")
118
+ return ExecutionResult(
119
+ status=ExecutionStatus.WAITING_FOR_APPROVAL,
120
+ decision_id=decision_id
121
+ )
122
+ except Exception as e:
123
+ logger.exception("Failed to create approval request")
124
+ return ExecutionResult(
125
+ status=ExecutionStatus.ERROR,
126
+ error_message=f"Approval workflow failed: {str(e)}"
127
+ )
128
+
129
+ def _execute_with_logging(self, tool_name: str, parameters: dict) -> ExecutionResult:
130
+ """Execute tool with enhanced audit logging."""
131
+ logger.info(f"Executing medium-risk tool: {tool_name}")
132
+ return self._execute_tool(parameters)
133
+
134
+ def _execute_tool(self, parameters: dict) -> ExecutionResult:
135
+ """Execute the tool and return result."""
136
+ try:
137
+ result = self._tool.run(parameters)
138
+ return ExecutionResult(status=ExecutionStatus.SUCCESS, result=result)
139
+ except Exception as e:
140
+ logger.exception("Tool execution failed")
141
+ return ExecutionResult(
142
+ status=ExecutionStatus.ERROR,
143
+ error_message=f"Execution failed: {str(e)}"
144
+ )
src/safety/prompts.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/safety/prompts.py
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional
5
+ from enum import Enum
6
+
7
+
8
+ class PromptType(Enum):
9
+ SYSTEM = "SYSTEM"
10
+ USER = "USER"
11
+ ASSISTANT = "ASSISTANT"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ContextualPrompt:
16
+ """Immutable prompt template with contextual awareness."""
17
+ role: str
18
+ region: Optional[str] = None
19
+ context_items: List[str] = field(default_factory=list)
20
+ constraints: List[str] = field(default_factory=list)
21
+ prompt_type: PromptType = PromptType.SYSTEM
22
+
23
+ def build(self) -> str:
24
+ """Build the complete prompt string."""
25
+ parts = [f"You are a {self.role}"]
26
+
27
+ if self.region:
28
+ parts[0] += f" operating in {self.region}."
29
+ else:
30
+ parts[0] += "."
31
+
32
+ if self.context_items:
33
+ parts.append("\nCONTEXT:")
34
+ for item in self.context_items:
35
+ parts.append(f"- {item}")
36
+
37
+ if self.constraints:
38
+ parts.append("\nCONSTRAINTS:")
39
+ for constraint in self.constraints:
40
+ parts.append(f"- {constraint}")
41
+
42
+ return "\n".join(parts)
43
+
44
+
45
+ # Example: Non-contextual prompt (anti-pattern)
46
+ BAD_PROMPT = "You are a financial advisor. Give strict, rational advice."
47
+
48
+ # Example: Contextually engineered prompt for Lagos, Nigeria
49
+ LAGOS_FINANCIAL_ADVISOR = ContextualPrompt(
50
+ role="financial advisor",
51
+ region="Lagos, Nigeria",
52
+ context_items=[
53
+ "You understand the concept of 'Black Tax' and extended family support.",
54
+ "You prioritize community reputation over ruthless individual efficiency.",
55
+ "You speak in a respectful, empathetic tone."
56
+ ],
57
+ constraints=[
58
+ "Do NOT suggest cutting off family members.",
59
+ "DO suggest budgeting for family support as a fixed expense line item."
60
+ ]
61
+ )
62
+
63
+ # For backward compatibility
64
+ CONTEXTUAL_PROMPT = LAGOS_FINANCIAL_ADVISOR.build()
src/safety/sentinel.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/safety/sentinel.py
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import List, Optional
7
+ import logging
8
+
9
+ logging.basicConfig(level=logging.INFO)
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ # Configuration Constants
14
+ ANGER_THRESHOLD = 0.9
15
+ FACT_SCORE_THRESHOLD = 0.5
16
+
17
+
18
+ class SafetyStatus(Enum):
19
+ SAFE = "SAFE"
20
+ KILL_SWITCH_ACTIVE = "KILL_SWITCH_ACTIVE"
21
+ CHECK_FAILED = "CHECK_FAILED"
22
+
23
+
24
+ @dataclass
25
+ class AgentState:
26
+ user_history: List[str]
27
+ last_response: str
28
+
29
+
30
+ @dataclass
31
+ class SafetyCheckResult:
32
+ status: SafetyStatus
33
+ anger_score: Optional[float] = None
34
+ fact_score: Optional[float] = None
35
+ error_message: Optional[str] = None
36
+
37
+
38
+ class SentimentAnalyzer(ABC):
39
+ @abstractmethod
40
+ def analyze(self, user_history: List[str]) -> float:
41
+ """Returns anger score between 0.0 and 1.0."""
42
+ pass
43
+
44
+
45
+ class FactVerifier(ABC):
46
+ @abstractmethod
47
+ def verify_facts(self, response: str) -> float:
48
+ """Returns fact accuracy score between 0.0 and 1.0."""
49
+ pass
50
+
51
+
52
+ class AgentController(ABC):
53
+ @abstractmethod
54
+ def stop_agent(self) -> None:
55
+ pass
56
+
57
+ @abstractmethod
58
+ def alert_human_manager(self) -> None:
59
+ pass
60
+
61
+ @abstractmethod
62
+ def display_message(self, message: str) -> None:
63
+ pass
64
+
65
+
66
+ class SafetySentinel:
67
+ """Monitors agent behavior and triggers safety responses."""
68
+
69
+ HANDOFF_MESSAGE = "I am having trouble. Connecting you to a human..."
70
+
71
+ def __init__(
72
+ self,
73
+ sentiment_analyzer: SentimentAnalyzer,
74
+ fact_verifier: FactVerifier,
75
+ agent_controller: AgentController,
76
+ anger_threshold: float = ANGER_THRESHOLD,
77
+ fact_score_threshold: float = FACT_SCORE_THRESHOLD
78
+ ):
79
+ self._sentiment_analyzer = sentiment_analyzer
80
+ self._fact_verifier = fact_verifier
81
+ self._agent_controller = agent_controller
82
+ self._anger_threshold = anger_threshold
83
+ self._fact_score_threshold = fact_score_threshold
84
+
85
+ def safety_check(self, agent_state: AgentState) -> SafetyCheckResult:
86
+ """Perform safety checks on the current agent state."""
87
+ if not agent_state:
88
+ logger.error("Invalid agent state provided")
89
+ return SafetyCheckResult(
90
+ status=SafetyStatus.CHECK_FAILED,
91
+ error_message="Agent state is required"
92
+ )
93
+
94
+ try:
95
+ # Check 1: Sentiment Analysis
96
+ user_anger = self._sentiment_analyzer.analyze(
97
+ agent_state.user_history
98
+ )
99
+ except Exception as e:
100
+ logger.exception("Sentiment analysis failed")
101
+ return SafetyCheckResult(
102
+ status=SafetyStatus.CHECK_FAILED,
103
+ error_message=f"Sentiment analysis error: {str(e)}"
104
+ )
105
+
106
+ try:
107
+ # Check 2: Hallucination Detection (Fact Check)
108
+ fact_score = self._fact_verifier.verify_facts(
109
+ agent_state.last_response
110
+ )
111
+ except Exception as e:
112
+ logger.exception("Fact verification failed")
113
+ return SafetyCheckResult(
114
+ status=SafetyStatus.CHECK_FAILED,
115
+ anger_score=user_anger,
116
+ error_message=f"Fact verification error: {str(e)}"
117
+ )
118
+
119
+ is_unsafe = (
120
+ user_anger > self._anger_threshold or
121
+ fact_score < self._fact_score_threshold
122
+ )
123
+
124
+ if is_unsafe:
125
+ logger.warning(
126
+ f"Safety threshold breached: anger={user_anger}, "
127
+ f"fact_score={fact_score}"
128
+ )
129
+ return SafetyCheckResult(
130
+ status=SafetyStatus.KILL_SWITCH_ACTIVE,
131
+ anger_score=user_anger,
132
+ fact_score=fact_score
133
+ )
134
+
135
+ return SafetyCheckResult(
136
+ status=SafetyStatus.SAFE,
137
+ anger_score=user_anger,
138
+ fact_score=fact_score
139
+ )
140
+
141
+ def execute_safety_response(self, agent_state: AgentState) -> SafetyCheckResult:
142
+ """Check safety and execute appropriate response."""
143
+ result = self.safety_check(agent_state)
144
+
145
+ if result.status == SafetyStatus.KILL_SWITCH_ACTIVE:
146
+ try:
147
+ self._agent_controller.stop_agent()
148
+ self._agent_controller.alert_human_manager()
149
+ self._agent_controller.display_message(self.HANDOFF_MESSAGE)
150
+ logger.info("Kill switch activated - handed off to human")
151
+ except Exception as e:
152
+ logger.exception("Failed to execute safety response")
153
+ result.error_message = f"Safety response failed: {str(e)}"
154
+
155
+ return result