agentctrl 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.
- agentctrl/__init__.py +59 -0
- agentctrl/adapters/__init__.py +22 -0
- agentctrl/adapters/crewai.py +135 -0
- agentctrl/adapters/langchain.py +145 -0
- agentctrl/adapters/openai_agents.py +141 -0
- agentctrl/authority_graph.py +362 -0
- agentctrl/conflict_detector.py +164 -0
- agentctrl/decorator.py +85 -0
- agentctrl/policy_engine.py +421 -0
- agentctrl/py.typed +0 -0
- agentctrl/risk_engine.py +247 -0
- agentctrl/runtime_gateway.py +419 -0
- agentctrl/types.py +92 -0
- agentctrl-0.1.0.dist-info/METADATA +491 -0
- agentctrl-0.1.0.dist-info/RECORD +17 -0
- agentctrl-0.1.0.dist-info/WHEEL +4 -0
- agentctrl-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Abu Jafar
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""agentctrl — Central validation pipeline (standalone version).
|
|
16
|
+
|
|
17
|
+
Runtime Governance Model — Layer 1
|
|
18
|
+
===================================
|
|
19
|
+
The pipeline evaluates every ActionProposal through 5 sequential decision
|
|
20
|
+
stages (autonomy → policy → authority → risk → conflict). Each stage can
|
|
21
|
+
short-circuit with BLOCK or ESCALATE. If all stages pass, the decision
|
|
22
|
+
is ALLOW.
|
|
23
|
+
|
|
24
|
+
The kill switch is an optional pre-gate callback (`kill_switch_fn`) so
|
|
25
|
+
the library works without platform dependencies. Fail-closed: any
|
|
26
|
+
unhandled exception during pipeline evaluation produces BLOCK.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import dataclasses
|
|
30
|
+
import logging
|
|
31
|
+
import time
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from typing import Callable, Awaitable
|
|
34
|
+
|
|
35
|
+
from .policy_engine import PolicyEngine, OPS
|
|
36
|
+
from .authority_graph import AuthorityGraphEngine
|
|
37
|
+
from .risk_engine import RiskEngine
|
|
38
|
+
from .conflict_detector import ConflictDetector
|
|
39
|
+
from .types import ActionProposal, PipelineHooks, PipelineStageResult, RuntimeDecisionRecord
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("agentctrl.runtime")
|
|
42
|
+
|
|
43
|
+
KillSwitchFn = Callable[[str], Awaitable[tuple[bool, str]]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RuntimeGateway:
|
|
47
|
+
"""
|
|
48
|
+
Sequential decision pipeline — the core of agentctrl's governance model.
|
|
49
|
+
|
|
50
|
+
Pipeline stages (sequential, each can short-circuit):
|
|
51
|
+
Pre-gate: Kill switch / emergency override (optional callback)
|
|
52
|
+
Pre-gate: Rate limiting (optional)
|
|
53
|
+
Stage 1: Autonomy check (with conditional scopes)
|
|
54
|
+
Stage 2: Policy validation
|
|
55
|
+
Stage 3: Authority resolution
|
|
56
|
+
Stage 4: Risk assessment
|
|
57
|
+
Stage 5: Conflict detection
|
|
58
|
+
Terminal: ALLOW / ESCALATE / BLOCK
|
|
59
|
+
|
|
60
|
+
Side effects are handled via optional PipelineHooks.
|
|
61
|
+
When hooks are None the pipeline runs as pure evaluation.
|
|
62
|
+
Fail-closed: any unhandled exception produces BLOCK.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
AUTONOMY_LEVEL_ACTIONS = {
|
|
66
|
+
0: [], # Suggest only — no actions permitted
|
|
67
|
+
1: [], # All actions require human approval
|
|
68
|
+
2: ["read", "query", "search", "summarize", "extract", "validate", "classify",
|
|
69
|
+
"invoice", "data", "report", "audit"], # routine ops; policy engine further governs
|
|
70
|
+
3: ["*"], # All actions within policy bounds
|
|
71
|
+
4: ["*"], # Full autonomy
|
|
72
|
+
5: ["*"], # Full independence — unrestricted within platform
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
policy_engine: PolicyEngine | None = None,
|
|
78
|
+
authority_engine: AuthorityGraphEngine | None = None,
|
|
79
|
+
hooks: PipelineHooks | None = None,
|
|
80
|
+
kill_switch_fn: KillSwitchFn | None = None,
|
|
81
|
+
autonomy_scopes: dict[int, list] | None = None,
|
|
82
|
+
risk_engine: RiskEngine | None = None,
|
|
83
|
+
rate_limits: list[dict] | None = None,
|
|
84
|
+
):
|
|
85
|
+
self.policy_engine = policy_engine or PolicyEngine()
|
|
86
|
+
self.authority_engine = authority_engine or AuthorityGraphEngine()
|
|
87
|
+
self.risk_engine = risk_engine or RiskEngine()
|
|
88
|
+
self.conflict_detector = ConflictDetector()
|
|
89
|
+
self.hooks = hooks
|
|
90
|
+
self._kill_switch_fn = kill_switch_fn
|
|
91
|
+
self._rate_limits = rate_limits or []
|
|
92
|
+
self._rate_buckets: dict[str, list[float]] = {}
|
|
93
|
+
if autonomy_scopes is not None:
|
|
94
|
+
self._autonomy_scopes = autonomy_scopes
|
|
95
|
+
else:
|
|
96
|
+
self._autonomy_scopes = self.AUTONOMY_LEVEL_ACTIONS
|
|
97
|
+
|
|
98
|
+
async def validate(self, proposal: ActionProposal) -> dict:
|
|
99
|
+
"""Run the sequential decision pipeline (pre-gate + 5 stages).
|
|
100
|
+
|
|
101
|
+
Fail-closed: any unhandled exception during evaluation produces
|
|
102
|
+
a BLOCK decision rather than propagating to the caller.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
return await self._run_pipeline(proposal)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.error("Pipeline error for %s (agent=%s): %s",
|
|
108
|
+
proposal.action_type, proposal.agent_id, exc)
|
|
109
|
+
return {
|
|
110
|
+
"proposal_id": proposal.proposal_id,
|
|
111
|
+
"agent_id": proposal.agent_id,
|
|
112
|
+
"action_type": proposal.action_type,
|
|
113
|
+
"decision": "BLOCK",
|
|
114
|
+
"reason": f"Governance pipeline error — blocked for safety. Error: {str(exc)[:200]}",
|
|
115
|
+
"risk_score": 0.0,
|
|
116
|
+
"risk_level": "CRITICAL",
|
|
117
|
+
"escalated_to": None,
|
|
118
|
+
"pipeline": [],
|
|
119
|
+
"decided_at": datetime.now(timezone.utc).isoformat(),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async def _run_pipeline(self, proposal: ActionProposal) -> dict:
|
|
123
|
+
"""Internal pipeline execution — exceptions caught by validate()."""
|
|
124
|
+
pid = proposal.proposal_id
|
|
125
|
+
logger.info("pipeline.start proposal=%s action=%s agent=%s",
|
|
126
|
+
pid, proposal.action_type, proposal.agent_id)
|
|
127
|
+
stages: list[PipelineStageResult] = []
|
|
128
|
+
|
|
129
|
+
# ── Pre-gate: Kill Switch ────────────────────────────────────────────
|
|
130
|
+
if self._kill_switch_fn:
|
|
131
|
+
ks_paused, ks_reason = await self._kill_switch_fn(proposal.agent_id)
|
|
132
|
+
if ks_paused:
|
|
133
|
+
stages.append(PipelineStageResult("kill_switch", "BLOCK",
|
|
134
|
+
{"kill_switch_active": True}, ks_reason))
|
|
135
|
+
return self._make_decision(proposal, stages, "BLOCK", ks_reason, 0.0, "CRITICAL")
|
|
136
|
+
|
|
137
|
+
# ── Pre-gate: Rate Limiting ──────────────────────────────────────────
|
|
138
|
+
rate_result = await self._check_rate_limit(proposal)
|
|
139
|
+
if rate_result is not None:
|
|
140
|
+
stages.append(rate_result)
|
|
141
|
+
return self._make_decision(proposal, stages, "BLOCK", rate_result.reason, 0.0, "LOW")
|
|
142
|
+
|
|
143
|
+
# ── Stage 1: Autonomy Level Check ────────────────────────────────────
|
|
144
|
+
stage1 = await self._check_autonomy(proposal)
|
|
145
|
+
stages.append(stage1)
|
|
146
|
+
if stage1.status == "BLOCK":
|
|
147
|
+
return self._make_decision(proposal, stages, "BLOCK", stage1.reason, 0.0, "LOW")
|
|
148
|
+
if stage1.status == "ESCALATE":
|
|
149
|
+
risk = await self.risk_engine.score(proposal)
|
|
150
|
+
stages.append(PipelineStageResult("risk_scoring", "INFO",
|
|
151
|
+
{"risk_score": risk.score, "risk_level": risk.level}))
|
|
152
|
+
return self._make_decision(proposal, stages, "ESCALATE", stage1.reason,
|
|
153
|
+
risk.score, risk.level, escalated_to="approver_required")
|
|
154
|
+
|
|
155
|
+
# ── Stage 2: Policy Validation ───────────────────────────────────────
|
|
156
|
+
stage2 = await self.policy_engine.validate(proposal)
|
|
157
|
+
stages.append(stage2)
|
|
158
|
+
if stage2.status in ("BLOCK", "ESCALATE"):
|
|
159
|
+
risk = await self.risk_engine.score(proposal)
|
|
160
|
+
return self._make_decision(proposal, stages, stage2.status, stage2.reason,
|
|
161
|
+
risk.score, risk.level)
|
|
162
|
+
|
|
163
|
+
# ── Stage 3: Authority Graph Resolution ──────────────────────────────
|
|
164
|
+
stage3 = await self.authority_engine.resolve(proposal)
|
|
165
|
+
stages.append(stage3)
|
|
166
|
+
if stage3.status in ("BLOCK", "ESCALATE"):
|
|
167
|
+
risk = await self.risk_engine.score(proposal)
|
|
168
|
+
escalated_to = stage3.details.get("escalate_to")
|
|
169
|
+
return self._make_decision(proposal, stages, stage3.status, stage3.reason,
|
|
170
|
+
risk.score, risk.level, escalated_to=escalated_to)
|
|
171
|
+
|
|
172
|
+
# ── Stage 4: Risk Scoring ────────────────────────────────────────────
|
|
173
|
+
risk = await self.risk_engine.score(proposal)
|
|
174
|
+
stage4 = PipelineStageResult(
|
|
175
|
+
stage="risk_scoring",
|
|
176
|
+
status="ESCALATE" if risk.level in ("HIGH", "CRITICAL") else "PASS",
|
|
177
|
+
details={"risk_score": risk.score, "risk_level": risk.level, "factors": risk.factors},
|
|
178
|
+
reason=f"Risk level: {risk.level} (score: {risk.score:.2f})",
|
|
179
|
+
)
|
|
180
|
+
stages.append(stage4)
|
|
181
|
+
if stage4.status == "ESCALATE":
|
|
182
|
+
return self._make_decision(proposal, stages, "ESCALATE", stage4.reason,
|
|
183
|
+
risk.score, risk.level)
|
|
184
|
+
|
|
185
|
+
# ── Stage 5: Conflict Detection ──────────────────────────────────────
|
|
186
|
+
stage5 = await self.conflict_detector.check(proposal)
|
|
187
|
+
stages.append(stage5)
|
|
188
|
+
if stage5.status == "BLOCK":
|
|
189
|
+
return self._make_decision(proposal, stages, "BLOCK", stage5.reason, risk.score, risk.level)
|
|
190
|
+
if stage5.status == "ESCALATE":
|
|
191
|
+
return self._make_decision(proposal, stages, "ESCALATE", stage5.reason, risk.score, risk.level)
|
|
192
|
+
|
|
193
|
+
# ── Terminal: All stages passed → ALLOW ──────────────────────────────
|
|
194
|
+
reason = f"All validation stages passed. Action '{proposal.action_type}' approved for execution."
|
|
195
|
+
return self._make_decision(proposal, stages, "ALLOW", reason, risk.score, risk.level)
|
|
196
|
+
|
|
197
|
+
async def _check_autonomy(self, proposal: ActionProposal) -> PipelineStageResult:
|
|
198
|
+
level = proposal.autonomy_level
|
|
199
|
+
action = proposal.action_type.split(".")[0] if "." in proposal.action_type else proposal.action_type
|
|
200
|
+
|
|
201
|
+
if level == 0:
|
|
202
|
+
return PipelineStageResult("autonomy_check", "BLOCK", {"autonomy_level": level},
|
|
203
|
+
"Level 0 agents cannot execute any actions (suggest-only mode).")
|
|
204
|
+
if level == 1:
|
|
205
|
+
return PipelineStageResult("autonomy_check", "ESCALATE", {"autonomy_level": level},
|
|
206
|
+
"Level 1 agents require explicit human approval for all actions.")
|
|
207
|
+
|
|
208
|
+
permitted = self._autonomy_scopes.get(level, [])
|
|
209
|
+
|
|
210
|
+
# Conditional scopes: entries can be structured dicts with conditions and trust thresholds
|
|
211
|
+
if isinstance(permitted, list):
|
|
212
|
+
for entry in permitted:
|
|
213
|
+
if isinstance(entry, dict):
|
|
214
|
+
matched = self._check_conditional_scope(entry, proposal, action)
|
|
215
|
+
if matched is True:
|
|
216
|
+
return PipelineStageResult(
|
|
217
|
+
"autonomy_check", "PASS",
|
|
218
|
+
{"autonomy_level": level, "action": action, "scope_type": "conditional"},
|
|
219
|
+
f"Action '{action}' matched conditional autonomy scope.",
|
|
220
|
+
)
|
|
221
|
+
if matched == "trust_insufficient":
|
|
222
|
+
trust_ctx = getattr(proposal, "trust_context", None) or {}
|
|
223
|
+
return PipelineStageResult(
|
|
224
|
+
"autonomy_check", "ESCALATE",
|
|
225
|
+
{"autonomy_level": level, "action": action,
|
|
226
|
+
"trust_total": trust_ctx.get("total_actions", 0),
|
|
227
|
+
"scope_type": "trust_gated"},
|
|
228
|
+
(f"Action '{action}' is trust-gated. Agent has not met the required "
|
|
229
|
+
f"trust threshold for autonomous execution."),
|
|
230
|
+
)
|
|
231
|
+
elif isinstance(entry, str):
|
|
232
|
+
if entry == "*" or entry == action:
|
|
233
|
+
return PipelineStageResult(
|
|
234
|
+
"autonomy_check", "PASS",
|
|
235
|
+
{"autonomy_level": level, "action": action},
|
|
236
|
+
f"Action '{action}' is within pre-approved scope for Level {level} agents.",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if isinstance(permitted, list) and all(isinstance(e, str) for e in permitted):
|
|
240
|
+
if "*" not in permitted and action not in permitted:
|
|
241
|
+
return PipelineStageResult("autonomy_check", "ESCALATE",
|
|
242
|
+
{"autonomy_level": level, "action": action, "permitted": permitted},
|
|
243
|
+
f"Action '{action}' is not in pre-approved scope for Level {level} agents.")
|
|
244
|
+
return PipelineStageResult("autonomy_check", "PASS",
|
|
245
|
+
{"autonomy_level": level, "action": action},
|
|
246
|
+
f"Action '{action}' is within pre-approved scope for Level {level} agents.")
|
|
247
|
+
|
|
248
|
+
return PipelineStageResult("autonomy_check", "ESCALATE",
|
|
249
|
+
{"autonomy_level": level, "action": action},
|
|
250
|
+
f"Action '{action}' is not in pre-approved scope for Level {level} agents.")
|
|
251
|
+
|
|
252
|
+
def _check_conditional_scope(self, scope_entry: dict, proposal: ActionProposal, action: str):
|
|
253
|
+
"""Evaluate a conditional autonomy scope entry.
|
|
254
|
+
|
|
255
|
+
Returns True if scope matches, "trust_insufficient" if action type matches
|
|
256
|
+
but trust is too low, or False if action type doesn't match.
|
|
257
|
+
"""
|
|
258
|
+
scope_action = scope_entry.get("action_type", "")
|
|
259
|
+
if scope_action != "*" and scope_action != action:
|
|
260
|
+
if not (scope_action.endswith(".*") and action.startswith(scope_action[:-2])):
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
conditions = scope_entry.get("conditions", [])
|
|
264
|
+
for cond in conditions:
|
|
265
|
+
param = cond.get("param", "")
|
|
266
|
+
op = cond.get("op", "lte")
|
|
267
|
+
value = cond.get("value")
|
|
268
|
+
actual = proposal.action_params.get(param)
|
|
269
|
+
if actual is None:
|
|
270
|
+
return False
|
|
271
|
+
op_fn = OPS.get(op)
|
|
272
|
+
if op_fn:
|
|
273
|
+
try:
|
|
274
|
+
if not op_fn(actual, value):
|
|
275
|
+
return False
|
|
276
|
+
except (TypeError, ValueError):
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
trust_threshold = scope_entry.get("trust_threshold")
|
|
280
|
+
if trust_threshold is not None:
|
|
281
|
+
trust_ctx = getattr(proposal, "trust_context", None) or {}
|
|
282
|
+
total_actions = trust_ctx.get("total_actions", 0)
|
|
283
|
+
if total_actions < trust_threshold:
|
|
284
|
+
return "trust_insufficient"
|
|
285
|
+
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
async def _check_rate_limit(self, proposal: ActionProposal) -> PipelineStageResult | None:
|
|
289
|
+
"""Pre-gate: check rate limits with burst detection and rate_pressure signal."""
|
|
290
|
+
if not self._rate_limits:
|
|
291
|
+
return None
|
|
292
|
+
agent_id = proposal.agent_id
|
|
293
|
+
now = time.monotonic()
|
|
294
|
+
max_pressure = 0.0
|
|
295
|
+
|
|
296
|
+
for rl in self._rate_limits:
|
|
297
|
+
target_type = rl.get("target_type", "global")
|
|
298
|
+
target_id = rl.get("target_id", "*")
|
|
299
|
+
max_req = rl.get("max_requests", 100)
|
|
300
|
+
window = rl.get("window_seconds", 60)
|
|
301
|
+
|
|
302
|
+
if target_type == "agent" and target_id != "*" and target_id != agent_id:
|
|
303
|
+
continue
|
|
304
|
+
if target_type == "action_type" and target_id != proposal.action_type:
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
key = f"rl:{target_type}:{target_id}"
|
|
308
|
+
bucket = self._rate_buckets.setdefault(key, [])
|
|
309
|
+
cutoff = now - window
|
|
310
|
+
self._rate_buckets[key] = [t for t in bucket if t > cutoff]
|
|
311
|
+
|
|
312
|
+
current_count = len(self._rate_buckets[key])
|
|
313
|
+
pressure = current_count / max(max_req, 1)
|
|
314
|
+
max_pressure = max(max_pressure, pressure)
|
|
315
|
+
|
|
316
|
+
if current_count >= max_req:
|
|
317
|
+
proposal.context["rate_pressure"] = round(min(pressure, 1.0), 3)
|
|
318
|
+
return PipelineStageResult(
|
|
319
|
+
"rate_limit", "BLOCK",
|
|
320
|
+
{"limit": max_req, "window_seconds": window, "rate_pressure": round(pressure, 3)},
|
|
321
|
+
f"Rate limit exceeded: {max_req} requests per {window}s for {target_type}:{target_id}.",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Burst detection: >50% of limit consumed in last 5 seconds
|
|
325
|
+
burst_window = min(5.0, window)
|
|
326
|
+
burst_cutoff = now - burst_window
|
|
327
|
+
burst_count = sum(1 for t in self._rate_buckets[key] if t > burst_cutoff)
|
|
328
|
+
burst_threshold = max(max_req * 0.5, 3)
|
|
329
|
+
if burst_count >= burst_threshold:
|
|
330
|
+
proposal.context["rate_pressure"] = round(min(pressure, 1.0), 3)
|
|
331
|
+
return PipelineStageResult(
|
|
332
|
+
"rate_limit", "BLOCK",
|
|
333
|
+
{"limit": max_req, "window_seconds": window, "burst": True,
|
|
334
|
+
"burst_count": burst_count, "burst_window": burst_window,
|
|
335
|
+
"rate_pressure": round(pressure, 3)},
|
|
336
|
+
(f"Burst detected: {burst_count} requests in {burst_window:.0f}s "
|
|
337
|
+
f"(threshold: {burst_threshold:.0f}) for {target_type}:{target_id}."),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
self._rate_buckets[key].append(now)
|
|
341
|
+
|
|
342
|
+
if max_pressure > 0:
|
|
343
|
+
proposal.context["rate_pressure"] = round(min(max_pressure, 1.0), 3)
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
def _make_decision(self, proposal, stages, decision, reason, risk_score, risk_level, escalated_to=None):
|
|
347
|
+
pipeline_dict = [
|
|
348
|
+
{"stage": s.stage, "status": s.status, "details": s.details, "reason": s.reason}
|
|
349
|
+
for s in stages
|
|
350
|
+
]
|
|
351
|
+
logger.info("pipeline.decision proposal=%s decision=%s risk=%s/%s stages_evaluated=%d action=%s agent=%s",
|
|
352
|
+
proposal.proposal_id, decision, risk_level, f"{risk_score:.2f}",
|
|
353
|
+
len(stages), proposal.action_type, proposal.agent_id)
|
|
354
|
+
|
|
355
|
+
if self.hooks:
|
|
356
|
+
if self.hooks.on_decision:
|
|
357
|
+
try:
|
|
358
|
+
self.hooks.on_decision(decision, proposal, risk_score, risk_level)
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
if decision == "BLOCK" and self.hooks.on_block_alert:
|
|
362
|
+
try:
|
|
363
|
+
import asyncio
|
|
364
|
+
asyncio.ensure_future(self.hooks.on_block_alert(proposal, reason, risk_level))
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
if self.hooks.on_broadcast:
|
|
368
|
+
try:
|
|
369
|
+
self.hooks.on_broadcast({
|
|
370
|
+
"type": "governance.decision",
|
|
371
|
+
"agent_id": proposal.agent_id,
|
|
372
|
+
"action_type": proposal.action_type,
|
|
373
|
+
"decision": decision,
|
|
374
|
+
"reason": reason,
|
|
375
|
+
"risk_score": risk_score,
|
|
376
|
+
"risk_level": risk_level,
|
|
377
|
+
})
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
record = RuntimeDecisionRecord(
|
|
382
|
+
proposal_id=proposal.proposal_id,
|
|
383
|
+
agent_id=proposal.agent_id,
|
|
384
|
+
action_type=proposal.action_type,
|
|
385
|
+
action_params=proposal.action_params,
|
|
386
|
+
pipeline_stages=stages,
|
|
387
|
+
decision=decision,
|
|
388
|
+
reason=reason,
|
|
389
|
+
risk_score=risk_score,
|
|
390
|
+
risk_level=risk_level,
|
|
391
|
+
escalated_to=escalated_to,
|
|
392
|
+
decided_at=datetime.now(timezone.utc),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
result = dataclasses.asdict(record)
|
|
396
|
+
result["pipeline"] = pipeline_dict
|
|
397
|
+
result["decided_at"] = record.decided_at.isoformat()
|
|
398
|
+
|
|
399
|
+
# Preserve cross-cutting signals in decision record
|
|
400
|
+
consequence_class = getattr(proposal, "consequence_class", None)
|
|
401
|
+
if consequence_class:
|
|
402
|
+
result["consequence_class"] = consequence_class
|
|
403
|
+
evidence = getattr(proposal, "evidence", None)
|
|
404
|
+
if evidence:
|
|
405
|
+
result["evidence"] = evidence
|
|
406
|
+
input_confidence = getattr(proposal, "input_confidence", None)
|
|
407
|
+
if input_confidence is not None:
|
|
408
|
+
result["input_confidence"] = input_confidence
|
|
409
|
+
rate_pressure = proposal.context.get("rate_pressure")
|
|
410
|
+
if rate_pressure is not None:
|
|
411
|
+
result["rate_pressure"] = rate_pressure
|
|
412
|
+
|
|
413
|
+
if self.hooks and self.hooks.on_audit:
|
|
414
|
+
try:
|
|
415
|
+
self.hooks.on_audit(result)
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
return result
|
agentctrl/types.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Abu Jafar
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""agentctrl — Shared data types for the governance pipeline."""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Callable, Awaitable
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CONSEQUENCE_CLASSES = {"reversible", "partially_reversible", "irreversible"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ActionProposal:
|
|
28
|
+
"""Structured proposal submitted by an agent for governance evaluation.
|
|
29
|
+
|
|
30
|
+
trust_context expected shape (optional — no-op when empty):
|
|
31
|
+
{"total_actions": int, "success_rate": float, "trust_balance": float}
|
|
32
|
+
"""
|
|
33
|
+
agent_id: str
|
|
34
|
+
action_type: str
|
|
35
|
+
action_params: dict = field(default_factory=dict)
|
|
36
|
+
workflow_id: str | None = None
|
|
37
|
+
context: dict = field(default_factory=dict)
|
|
38
|
+
autonomy_level: int = 1
|
|
39
|
+
proposal_id: str = field(default_factory=lambda: str(uuid4()))
|
|
40
|
+
submitted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
41
|
+
trust_context: dict = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
# Cross-cutting signals — optional, backward-compatible.
|
|
44
|
+
consequence_class: str | None = None # "reversible" | "partially_reversible" | "irreversible"
|
|
45
|
+
evidence: dict | None = None # {type: str, reference: str, timestamp: str}
|
|
46
|
+
input_confidence: float | None = None # 0.0–1.0; None treated as 0.7 default by risk engine
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class PipelineStageResult:
|
|
51
|
+
"""Result from a single pipeline stage."""
|
|
52
|
+
stage: str
|
|
53
|
+
status: str # PASS | FAIL | ESCALATE | BLOCK
|
|
54
|
+
details: dict = field(default_factory=dict)
|
|
55
|
+
reason: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class PipelineHooks:
|
|
60
|
+
"""Optional callbacks for pipeline side effects (metrics, WS, alerting).
|
|
61
|
+
|
|
62
|
+
When None, the side effect is skipped — enabling standalone library use
|
|
63
|
+
without platform dependencies.
|
|
64
|
+
"""
|
|
65
|
+
on_decision: Callable[[str, "ActionProposal", float, str], None] | None = None
|
|
66
|
+
on_block_alert: Callable[["ActionProposal", str, str], Awaitable[None]] | None = None
|
|
67
|
+
on_broadcast: Callable[[dict], None] | None = None
|
|
68
|
+
on_audit: Callable[[dict], None] | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class EscalationTarget:
|
|
73
|
+
"""Structured escalation target (replaces bare string `escalated_to`)."""
|
|
74
|
+
target_type: str # "agent" | "role" | "group"
|
|
75
|
+
target_id: str
|
|
76
|
+
reason: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RuntimeDecisionRecord:
|
|
81
|
+
"""Complete decision record produced by the pipeline."""
|
|
82
|
+
proposal_id: str
|
|
83
|
+
agent_id: str
|
|
84
|
+
action_type: str
|
|
85
|
+
action_params: dict
|
|
86
|
+
pipeline_stages: list[PipelineStageResult]
|
|
87
|
+
decision: str # ALLOW | ESCALATE | BLOCK
|
|
88
|
+
reason: str
|
|
89
|
+
risk_score: float
|
|
90
|
+
risk_level: str
|
|
91
|
+
escalated_to: "EscalationTarget | str | None" = None
|
|
92
|
+
decided_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|