Rohan03 commited on
Commit
658c9d5
·
verified ·
1 Parent(s): ecad107

harden: defensive core — null safety, timeouts, graceful degradation, domain-agnostic

Browse files
Files changed (1) hide show
  1. purpose_agent/hardening.py +191 -0
purpose_agent/hardening.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hardening.py — Defensive utilities for production-grade execution.
3
+
4
+ Applied across the critical path to prevent crashes from:
5
+ - None propagation
6
+ - LLM timeouts
7
+ - Malformed parser output
8
+ - Environment exceptions
9
+ - Type mismatches at boundaries
10
+
11
+ Usage:
12
+ from purpose_agent.hardening import safe_params, llm_call_with_timeout, safe_action
13
+
14
+ All functions are pure — no side effects, no state.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import signal
20
+ import threading
21
+ import time
22
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
23
+ from typing import Any, Callable, TypeVar
24
+
25
+ logger = logging.getLogger("purpose_agent.hardening")
26
+
27
+ T = TypeVar("T")
28
+
29
+
30
+ # ═══════════════════════════════════════════════════════════════
31
+ # Null Safety
32
+ # ═══════════════════════════════════════════════════════════════
33
+
34
+ def safe_params(params: Any) -> dict[str, Any]:
35
+ """
36
+ Normalize action params to a guaranteed dict.
37
+ Handles: None, string, list, or any non-dict garbage from parsers.
38
+ """
39
+ if isinstance(params, dict):
40
+ return params
41
+ if params is None:
42
+ return {}
43
+ if isinstance(params, str):
44
+ # Parser sometimes returns the raw string
45
+ return {"_raw": params}
46
+ return {}
47
+
48
+
49
+ def safe_string(value: Any, default: str = "", max_len: int = 10000) -> str:
50
+ """Guarantee a string value. Never returns None."""
51
+ if value is None:
52
+ return default
53
+ s = str(value)
54
+ return s[:max_len] if len(s) > max_len else s
55
+
56
+
57
+ def safe_float(value: Any, default: float = 0.0, min_val: float = 0.0, max_val: float = 10.0) -> float:
58
+ """Guarantee a bounded float. Never raises, never returns None."""
59
+ try:
60
+ f = float(str(value).rstrip('.').rstrip(','))
61
+ return max(min_val, min(max_val, f))
62
+ except (ValueError, TypeError):
63
+ return default
64
+
65
+
66
+ def safe_dict_get(d: Any, key: str, default: Any = "") -> Any:
67
+ """Safe get from potentially-None dict."""
68
+ if not isinstance(d, dict):
69
+ return default
70
+ val = d.get(key, default)
71
+ return val if val is not None else default
72
+
73
+
74
+ # ═══════════════════════════════════════════════════════════════
75
+ # Timeout Wrapper
76
+ # ═══════════════════════════════════════════════════════════════
77
+
78
+ def with_timeout(fn: Callable[..., T], timeout_s: float = 30.0, default: T = None, label: str = "") -> Callable[..., T]:
79
+ """
80
+ Wrap a function with a timeout. Returns default if timeout exceeded.
81
+
82
+ Uses ThreadPoolExecutor (works on all platforms, no signals needed).
83
+
84
+ Usage:
85
+ safe_generate = with_timeout(llm.generate, timeout_s=30.0, default="", label="llm.generate")
86
+ result = safe_generate(messages, temperature=0.7)
87
+ """
88
+ def wrapper(*args, **kwargs) -> T:
89
+ with ThreadPoolExecutor(max_workers=1) as executor:
90
+ future = executor.submit(fn, *args, **kwargs)
91
+ try:
92
+ return future.result(timeout=timeout_s)
93
+ except FuturesTimeout:
94
+ logger.error(f"TIMEOUT ({timeout_s}s): {label or fn.__name__}")
95
+ return default
96
+ except Exception as e:
97
+ logger.error(f"ERROR in {label or fn.__name__}: {type(e).__name__}: {e}")
98
+ return default
99
+ return wrapper
100
+
101
+
102
+ def llm_call_with_timeout(
103
+ llm_fn: Callable,
104
+ args: tuple = (),
105
+ kwargs: dict | None = None,
106
+ timeout_s: float = 60.0,
107
+ default: str = "",
108
+ label: str = "llm_call",
109
+ ) -> str:
110
+ """
111
+ Execute a single LLM call with timeout and error recovery.
112
+
113
+ Returns default string on any failure (timeout, network error, parse error).
114
+ NEVER raises to the caller.
115
+ """
116
+ kwargs = kwargs or {}
117
+ with ThreadPoolExecutor(max_workers=1) as executor:
118
+ future = executor.submit(llm_fn, *args, **kwargs)
119
+ try:
120
+ result = future.result(timeout=timeout_s)
121
+ if result is None:
122
+ return default
123
+ return str(result)
124
+ except FuturesTimeout:
125
+ logger.error(f"LLM TIMEOUT ({timeout_s}s): {label}")
126
+ return default
127
+ except Exception as e:
128
+ logger.error(f"LLM ERROR ({label}): {type(e).__name__}: {e}")
129
+ return default
130
+
131
+
132
+ # ═══════════════════════════════════════════════════════════════
133
+ # Graceful Degradation
134
+ # ═══════════════════════════════════════════════════════════════
135
+
136
+ def graceful(fn: Callable[..., T], default: T, label: str = "") -> Callable[..., T]:
137
+ """
138
+ Decorator: function never raises. Returns default on any exception.
139
+
140
+ Usage:
141
+ @graceful(default={}, label="parse_response")
142
+ def parse_response(text): ...
143
+ """
144
+ def wrapper(*args, **kwargs) -> T:
145
+ try:
146
+ result = fn(*args, **kwargs)
147
+ return result if result is not None else default
148
+ except Exception as e:
149
+ logger.warning(f"Graceful degradation ({label or fn.__name__}): {type(e).__name__}: {e}")
150
+ return default
151
+ wrapper.__name__ = fn.__name__
152
+ wrapper.__doc__ = fn.__doc__
153
+ return wrapper
154
+
155
+
156
+ # ═══════════════════════════════════════════════════════════════
157
+ # Input Validation
158
+ # ═══════════════════════════════════════════════════════════════
159
+
160
+ class ValidationError(ValueError):
161
+ """Raised when framework input validation fails. Always has actionable message."""
162
+ pass
163
+
164
+
165
+ def validate_purpose(purpose: str) -> str:
166
+ """Validate and normalize a purpose string."""
167
+ if not purpose or not isinstance(purpose, str):
168
+ raise ValidationError(
169
+ "purpose must be a non-empty string. "
170
+ "Example: pa.purpose('Help me write Python code')"
171
+ )
172
+ purpose = purpose.strip()
173
+ if len(purpose) < 3:
174
+ raise ValidationError(
175
+ f"purpose too short ({len(purpose)} chars). "
176
+ "Provide a meaningful description of what you want the agent to do."
177
+ )
178
+ if len(purpose) > 5000:
179
+ purpose = purpose[:5000]
180
+ logger.warning("Purpose truncated to 5000 chars")
181
+ return purpose
182
+
183
+
184
+ def validate_model_spec(spec: str) -> str:
185
+ """Validate a model spec string."""
186
+ if not spec or not isinstance(spec, str):
187
+ raise ValidationError(
188
+ "model must be a string like 'ollama:qwen3:1.7b' or 'openrouter:meta-llama/llama-3.3-70b-instruct'. "
189
+ "See docs for supported providers."
190
+ )
191
+ return spec.strip()