agentctrl 0.2.1__tar.gz → 0.2.2__tar.gz
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-0.2.2/.gitignore +76 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/CHANGELOG.md +13 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/CONTRIBUTING.md +1 -1
- {agentctrl-0.2.1 → agentctrl-0.2.2}/PKG-INFO +5 -5
- {agentctrl-0.2.1 → agentctrl-0.2.2}/README.md +4 -3
- {agentctrl-0.2.1 → agentctrl-0.2.2}/pyproject.toml +1 -1
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/runtime_gateway.py +45 -6
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/types.py +1 -1
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_pipeline.py +61 -1
- agentctrl-0.2.2/uv.lock +4019 -0
- agentctrl-0.2.1/.github/workflows/ci.yml +0 -66
- agentctrl-0.2.1/.gitignore +0 -9
- agentctrl-0.2.1/LICENSE +0 -190
- {agentctrl-0.2.1 → agentctrl-0.2.2}/SECURITY.md +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/examples/bare_python.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/examples/inbound_governance.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/examples/langchain_tool.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/examples/openai_function_call.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/__init__.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/__main__.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/adapters/__init__.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/adapters/crewai.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/adapters/langchain.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/adapters/openai_agents.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/authority_graph.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/cli.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/conflict_detector.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/decorator.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/policy_engine.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/py.typed +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/src/agentctrl/risk_engine.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_authority_graph.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_boundary.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_decorator.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_parity_features.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_policy_engine.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_risk_engine.py +0 -0
- {agentctrl-0.2.1 → agentctrl-0.2.2}/tests/test_v02_features.py +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# ── Python ───────────────────────────────────────────
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
.eggs/
|
|
11
|
+
*.so
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
backend/.venv/
|
|
16
|
+
venv/
|
|
17
|
+
env/
|
|
18
|
+
|
|
19
|
+
# ── Node / Next.js ────────────────────────────────────
|
|
20
|
+
node_modules/
|
|
21
|
+
frontend/node_modules/
|
|
22
|
+
frontend/.next/
|
|
23
|
+
frontend/.next.trash/
|
|
24
|
+
frontend/out/
|
|
25
|
+
frontend/.vercel/
|
|
26
|
+
.npm
|
|
27
|
+
|
|
28
|
+
# ── Environment & Secrets ─────────────────────────────
|
|
29
|
+
.env
|
|
30
|
+
.env.local
|
|
31
|
+
.env.production
|
|
32
|
+
.env.*.local
|
|
33
|
+
# Keep .env.example (it's safe to share)
|
|
34
|
+
!.env.example
|
|
35
|
+
# Integration credentials saved via in-platform UI (contains API keys)
|
|
36
|
+
integrations.json
|
|
37
|
+
|
|
38
|
+
# ── Databases & Storage ───────────────────────────────
|
|
39
|
+
*.db
|
|
40
|
+
*.sqlite
|
|
41
|
+
*.sqlite3
|
|
42
|
+
postgres-data/
|
|
43
|
+
redis-data/
|
|
44
|
+
|
|
45
|
+
# ── IDE & OS ──────────────────────────────────────────
|
|
46
|
+
.DS_Store
|
|
47
|
+
.DS_Store?
|
|
48
|
+
._*
|
|
49
|
+
.Spotlight-V100
|
|
50
|
+
.Trashes
|
|
51
|
+
ehthumbs.db
|
|
52
|
+
Thumbs.db
|
|
53
|
+
.idea/
|
|
54
|
+
.vscode/
|
|
55
|
+
.cursor/plans/
|
|
56
|
+
.cursor/debug-*.log
|
|
57
|
+
*.swp
|
|
58
|
+
*.swo
|
|
59
|
+
|
|
60
|
+
# ── Logs ──────────────────────────────────────────────
|
|
61
|
+
*.log
|
|
62
|
+
logs/
|
|
63
|
+
npm-debug.log*
|
|
64
|
+
yarn-debug.log*
|
|
65
|
+
|
|
66
|
+
# ── Docker ────────────────────────────────────────────
|
|
67
|
+
.docker/
|
|
68
|
+
|
|
69
|
+
# ── Test/Build Caches ─────────────────────────────────
|
|
70
|
+
.pytest_cache/
|
|
71
|
+
.ruff_cache/
|
|
72
|
+
|
|
73
|
+
# ── Misc ──────────────────────────────────────────────
|
|
74
|
+
*.bak
|
|
75
|
+
*.tmp
|
|
76
|
+
.cache/
|
|
@@ -6,6 +6,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pr
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.2.2] — 2026-04-12
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Advisory context on early exit.** When a pipeline stage short-circuits with ESCALATE or BLOCK, remaining stages (risk, conflict) still run as `ADVISORY` — their results are appended to the decision record for reviewer visibility but do not change the decision.
|
|
13
|
+
- New `ADVISORY` status for `PipelineStageResult` (non-decision, informational).
|
|
14
|
+
- 3 new tests: `test_advisory_context_on_autonomy_escalate`, `test_advisory_context_on_early_exit`, `test_allow_has_no_advisory_stages`.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `_run_pipeline()` early-exit paths now call `_collect_advisory_context()` instead of returning immediately.
|
|
18
|
+
- Test count updated to 8 (was 5 before advisory context tests).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
9
22
|
## [0.2.1] — 2026-04-11
|
|
10
23
|
|
|
11
24
|
### Added
|
|
@@ -33,7 +33,7 @@ This installs the library in editable mode with all optional dependencies (netwo
|
|
|
33
33
|
python -m pytest tests/ -v
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
79 tests total (78 pass, 1 skipped). No external services required — everything runs in-process.
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentctrl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Institutional control layer for AI agent actions — authority, policy, risk, and audit before execution.
|
|
5
5
|
Project-URL: Homepage, https://moeintel.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/moeintel/AgentCTRL
|
|
7
7
|
Project-URL: Issues, https://github.com/moeintel/AgentCTRL/issues
|
|
8
8
|
Author: MoeIntel
|
|
9
9
|
License-Expression: Apache-2.0
|
|
10
|
-
License-File: LICENSE
|
|
11
10
|
Keywords: ai-agents,authority,crewai,governance,langchain,openai,policy-engine,risk-scoring
|
|
12
11
|
Classifier: Development Status :: 3 - Alpha
|
|
13
12
|
Classifier: Intended Audience :: Developers
|
|
@@ -117,7 +116,7 @@ Those are institutional controls. They existed for human employees. They need to
|
|
|
117
116
|
- **Fail-closed.** Any pipeline error produces BLOCK, never silent approval.
|
|
118
117
|
- **Structural enforcement.** Policies are operator-based rule matching, not prompt instructions. Authority is graph traversal. Risk is weighted factor scoring. None of this is prompt engineering.
|
|
119
118
|
|
|
120
|
-
> **Status:**
|
|
119
|
+
> **Status:** 79 tests passing. Published on [PyPI](https://pypi.org/project/agentctrl/).
|
|
121
120
|
|
|
122
121
|
---
|
|
123
122
|
|
|
@@ -334,7 +333,7 @@ agent = Agent(role="analyst", tools=[governed_tool])
|
|
|
334
333
|
|
|
335
334
|
## Decision Pipeline
|
|
336
335
|
|
|
337
|
-
Every action passes through 5 stages in order. Each can short-circuit.
|
|
336
|
+
Every action passes through 5 stages in order. Each can short-circuit with BLOCK or ESCALATE. When a stage short-circuits, remaining stages still run as **ADVISORY** — their results are appended to the decision record for reviewer visibility but do not change the decision.
|
|
338
337
|
|
|
339
338
|
```
|
|
340
339
|
Agent proposes action
|
|
@@ -346,6 +345,7 @@ Agent proposes action
|
|
|
346
345
|
→ Risk Scoring (how risky is this action in context?)
|
|
347
346
|
→ Conflict Detection (does this clash with other active workflows?)
|
|
348
347
|
→ Decision: ALLOW / ESCALATE / BLOCK
|
|
348
|
+
(+ ADVISORY stages from remaining pipeline on early exit)
|
|
349
349
|
```
|
|
350
350
|
|
|
351
351
|
### Policy Engine
|
|
@@ -441,7 +441,7 @@ result = await gateway.validate(proposal)
|
|
|
441
441
|
python -m pytest tests/ -v
|
|
442
442
|
```
|
|
443
443
|
|
|
444
|
-
|
|
444
|
+
79 tests covering: pipeline stages, advisory context, fail-closed behavior, policy evaluation (AND/OR groups, 14 operators, temporal conditions), authority graph (delegation, SoD, limits, decay), risk scoring (13 dimensions, trust calibration, consequence class), conflict detection, `@governed` decorator, CLI, demo, audit logging, subscriptable record, empty authority default, instance isolation, and library boundary.
|
|
445
445
|
|
|
446
446
|
## Requirements
|
|
447
447
|
|
|
@@ -76,7 +76,7 @@ Those are institutional controls. They existed for human employees. They need to
|
|
|
76
76
|
- **Fail-closed.** Any pipeline error produces BLOCK, never silent approval.
|
|
77
77
|
- **Structural enforcement.** Policies are operator-based rule matching, not prompt instructions. Authority is graph traversal. Risk is weighted factor scoring. None of this is prompt engineering.
|
|
78
78
|
|
|
79
|
-
> **Status:**
|
|
79
|
+
> **Status:** 79 tests passing. Published on [PyPI](https://pypi.org/project/agentctrl/).
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
@@ -293,7 +293,7 @@ agent = Agent(role="analyst", tools=[governed_tool])
|
|
|
293
293
|
|
|
294
294
|
## Decision Pipeline
|
|
295
295
|
|
|
296
|
-
Every action passes through 5 stages in order. Each can short-circuit.
|
|
296
|
+
Every action passes through 5 stages in order. Each can short-circuit with BLOCK or ESCALATE. When a stage short-circuits, remaining stages still run as **ADVISORY** — their results are appended to the decision record for reviewer visibility but do not change the decision.
|
|
297
297
|
|
|
298
298
|
```
|
|
299
299
|
Agent proposes action
|
|
@@ -305,6 +305,7 @@ Agent proposes action
|
|
|
305
305
|
→ Risk Scoring (how risky is this action in context?)
|
|
306
306
|
→ Conflict Detection (does this clash with other active workflows?)
|
|
307
307
|
→ Decision: ALLOW / ESCALATE / BLOCK
|
|
308
|
+
(+ ADVISORY stages from remaining pipeline on early exit)
|
|
308
309
|
```
|
|
309
310
|
|
|
310
311
|
### Policy Engine
|
|
@@ -400,7 +401,7 @@ result = await gateway.validate(proposal)
|
|
|
400
401
|
python -m pytest tests/ -v
|
|
401
402
|
```
|
|
402
403
|
|
|
403
|
-
|
|
404
|
+
79 tests covering: pipeline stages, advisory context, fail-closed behavior, policy evaluation (AND/OR groups, 14 operators, temporal conditions), authority graph (delegation, SoD, limits, decay), risk scoring (13 dimensions, trust calibration, consequence class), conflict detection, `@governed` decorator, CLI, demo, audit logging, subscriptable record, empty authority default, instance isolation, and library boundary.
|
|
404
405
|
|
|
405
406
|
## Requirements
|
|
406
407
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentctrl"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Institutional control layer for AI agent actions — authority, policy, risk, and audit before execution."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -19,7 +19,9 @@ Runtime Governance Model — Layer 1
|
|
|
19
19
|
The pipeline evaluates every ActionProposal through 5 sequential decision
|
|
20
20
|
stages (autonomy → policy → authority → risk → conflict). Each stage can
|
|
21
21
|
short-circuit with BLOCK or ESCALATE. If all stages pass, the decision
|
|
22
|
-
is ALLOW.
|
|
22
|
+
is ALLOW. On early ESCALATE/BLOCK, remaining stages still run as ADVISORY
|
|
23
|
+
context — their results are appended to the decision record for reviewer
|
|
24
|
+
visibility but do not change the decision.
|
|
23
25
|
|
|
24
26
|
The kill switch is an optional pre-gate callback (`kill_switch_fn`) so
|
|
25
27
|
the library works without platform dependencies. Fail-closed: any
|
|
@@ -154,9 +156,8 @@ class RuntimeGateway:
|
|
|
154
156
|
if stage1.status == "BLOCK":
|
|
155
157
|
return self._make_decision(proposal, stages, "BLOCK", stage1.reason, 0.0, "LOW")
|
|
156
158
|
if stage1.status == "ESCALATE":
|
|
157
|
-
risk = await self.
|
|
158
|
-
stages.
|
|
159
|
-
{"risk_score": risk.score, "risk_level": risk.level}))
|
|
159
|
+
risk, advisory = await self._collect_advisory_context(proposal, from_stage=1)
|
|
160
|
+
stages.extend(advisory)
|
|
160
161
|
return self._make_decision(proposal, stages, "ESCALATE", stage1.reason,
|
|
161
162
|
risk.score, risk.level, escalated_to="approver_required")
|
|
162
163
|
|
|
@@ -164,7 +165,8 @@ class RuntimeGateway:
|
|
|
164
165
|
stage2 = await self.policy_engine.validate(proposal)
|
|
165
166
|
stages.append(stage2)
|
|
166
167
|
if stage2.status in ("BLOCK", "ESCALATE"):
|
|
167
|
-
risk = await self.
|
|
168
|
+
risk, advisory = await self._collect_advisory_context(proposal, from_stage=2)
|
|
169
|
+
stages.extend(advisory)
|
|
168
170
|
return self._make_decision(proposal, stages, stage2.status, stage2.reason,
|
|
169
171
|
risk.score, risk.level)
|
|
170
172
|
|
|
@@ -172,7 +174,8 @@ class RuntimeGateway:
|
|
|
172
174
|
stage3 = await self.authority_engine.resolve(proposal)
|
|
173
175
|
stages.append(stage3)
|
|
174
176
|
if stage3.status in ("BLOCK", "ESCALATE"):
|
|
175
|
-
risk = await self.
|
|
177
|
+
risk, advisory = await self._collect_advisory_context(proposal, from_stage=3)
|
|
178
|
+
stages.extend(advisory)
|
|
176
179
|
escalated_to = stage3.details.get("escalate_to")
|
|
177
180
|
return self._make_decision(proposal, stages, stage3.status, stage3.reason,
|
|
178
181
|
risk.score, risk.level, escalated_to=escalated_to)
|
|
@@ -187,6 +190,8 @@ class RuntimeGateway:
|
|
|
187
190
|
)
|
|
188
191
|
stages.append(stage4)
|
|
189
192
|
if stage4.status == "ESCALATE":
|
|
193
|
+
_risk, advisory = await self._collect_advisory_context(proposal, from_stage=4)
|
|
194
|
+
stages.extend(advisory)
|
|
190
195
|
return self._make_decision(proposal, stages, "ESCALATE", stage4.reason,
|
|
191
196
|
risk.score, risk.level)
|
|
192
197
|
|
|
@@ -202,6 +207,40 @@ class RuntimeGateway:
|
|
|
202
207
|
reason = f"All validation stages passed. Action '{proposal.action_type}' approved for execution."
|
|
203
208
|
return self._make_decision(proposal, stages, "ALLOW", reason, risk.score, risk.level)
|
|
204
209
|
|
|
210
|
+
async def _collect_advisory_context(
|
|
211
|
+
self, proposal: ActionProposal, from_stage: int,
|
|
212
|
+
) -> tuple:
|
|
213
|
+
"""Run remaining pipeline stages as non-decision ADVISORY context.
|
|
214
|
+
|
|
215
|
+
Gives the human reviewer visibility into what risk and conflict
|
|
216
|
+
would have said, even though an earlier stage already decided.
|
|
217
|
+
Returns (risk_result, list_of_advisory_stages).
|
|
218
|
+
``from_stage`` is the stage number that triggered the early exit
|
|
219
|
+
(1=autonomy, 2=policy, 3=authority, 4=risk).
|
|
220
|
+
"""
|
|
221
|
+
advisory_stages: list[PipelineStageResult] = []
|
|
222
|
+
if from_stage < 4:
|
|
223
|
+
risk = await self.risk_engine.score(proposal)
|
|
224
|
+
advisory_stages.append(
|
|
225
|
+
PipelineStageResult(
|
|
226
|
+
"risk_scoring", "ADVISORY",
|
|
227
|
+
{"risk_score": risk.score, "risk_level": risk.level, "factors": risk.factors},
|
|
228
|
+
f"Advisory: Risk level {risk.level} (score: {risk.score:.2f})",
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
risk = None
|
|
233
|
+
if from_stage < 5:
|
|
234
|
+
conflict = await self.conflict_detector.check(proposal)
|
|
235
|
+
advisory_stages.append(
|
|
236
|
+
PipelineStageResult(
|
|
237
|
+
"conflict_detection", "ADVISORY",
|
|
238
|
+
{"original_status": conflict.status, **(conflict.details or {})},
|
|
239
|
+
f"Advisory: {conflict.reason}",
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return risk, advisory_stages
|
|
243
|
+
|
|
205
244
|
async def _check_autonomy(self, proposal: ActionProposal) -> PipelineStageResult:
|
|
206
245
|
level = proposal.autonomy_level
|
|
207
246
|
action = proposal.action_type.split(".")[0] if "." in proposal.action_type else proposal.action_type
|
|
@@ -51,7 +51,7 @@ class ActionProposal:
|
|
|
51
51
|
class PipelineStageResult:
|
|
52
52
|
"""Result from a single pipeline stage."""
|
|
53
53
|
stage: str
|
|
54
|
-
status: str # PASS | FAIL | ESCALATE | BLOCK
|
|
54
|
+
status: str # PASS | FAIL | ESCALATE | BLOCK | ADVISORY
|
|
55
55
|
details: dict = field(default_factory=dict)
|
|
56
56
|
reason: str = ""
|
|
57
57
|
|
|
@@ -84,7 +84,7 @@ async def test_hooks_called():
|
|
|
84
84
|
|
|
85
85
|
decisions = []
|
|
86
86
|
hooks = PipelineHooks(
|
|
87
|
-
on_decision=lambda d, p, s,
|
|
87
|
+
on_decision=lambda d, p, s, log: decisions.append(d),
|
|
88
88
|
)
|
|
89
89
|
gateway = RuntimeGateway(hooks=hooks)
|
|
90
90
|
await gateway.validate(ActionProposal(
|
|
@@ -94,3 +94,63 @@ async def test_hooks_called():
|
|
|
94
94
|
autonomy_level=2,
|
|
95
95
|
))
|
|
96
96
|
assert len(decisions) == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_advisory_context_on_autonomy_escalate():
|
|
101
|
+
"""Level 1 agent escalates at autonomy; risk + conflict run as ADVISORY."""
|
|
102
|
+
from agentctrl import RuntimeGateway, ActionProposal
|
|
103
|
+
|
|
104
|
+
gateway = RuntimeGateway()
|
|
105
|
+
result = await gateway.validate(ActionProposal(
|
|
106
|
+
agent_id="junior-agent",
|
|
107
|
+
action_type="email.send",
|
|
108
|
+
action_params={"to": "user@example.com"},
|
|
109
|
+
autonomy_level=1,
|
|
110
|
+
))
|
|
111
|
+
assert result["decision"] == "ESCALATE"
|
|
112
|
+
stages = result["pipeline"]
|
|
113
|
+
advisory_stages = [s for s in stages if s["status"] == "ADVISORY"]
|
|
114
|
+
assert len(advisory_stages) == 2, f"Expected 2 advisory stages, got {len(advisory_stages)}"
|
|
115
|
+
advisory_names = {s["stage"] for s in advisory_stages}
|
|
116
|
+
assert "risk_scoring" in advisory_names
|
|
117
|
+
assert "conflict_detection" in advisory_names
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_advisory_context_on_early_exit():
|
|
122
|
+
"""Early BLOCK/ESCALATE still collects advisory risk + conflict stages."""
|
|
123
|
+
from agentctrl import RuntimeGateway, ActionProposal
|
|
124
|
+
from agentctrl.policy_engine import PolicyEngine
|
|
125
|
+
|
|
126
|
+
policies = [{"action_type": "delete.*", "effect": "BLOCK", "reason": "Deletes are forbidden"}]
|
|
127
|
+
gateway = RuntimeGateway(policy_engine=PolicyEngine(policies=policies))
|
|
128
|
+
result = await gateway.validate(ActionProposal(
|
|
129
|
+
agent_id="analyst",
|
|
130
|
+
action_type="delete.records",
|
|
131
|
+
action_params={},
|
|
132
|
+
autonomy_level=3,
|
|
133
|
+
))
|
|
134
|
+
assert result["decision"] in ("BLOCK", "ESCALATE")
|
|
135
|
+
advisory_stages = [s for s in result["pipeline"] if s["status"] == "ADVISORY"]
|
|
136
|
+
assert len(advisory_stages) >= 1, "At least one ADVISORY stage expected"
|
|
137
|
+
advisory_names = {s["stage"] for s in advisory_stages}
|
|
138
|
+
assert "risk_scoring" in advisory_names or "conflict_detection" in advisory_names
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_allow_has_no_advisory_stages():
|
|
143
|
+
"""Normal ALLOW path has no ADVISORY stages — all stages run as real decisions."""
|
|
144
|
+
from agentctrl import RuntimeGateway, ActionProposal
|
|
145
|
+
|
|
146
|
+
gateway = RuntimeGateway()
|
|
147
|
+
result = await gateway.validate(ActionProposal(
|
|
148
|
+
agent_id="ap_analyst",
|
|
149
|
+
action_type="invoice.approve",
|
|
150
|
+
action_params={"amount": 1000},
|
|
151
|
+
autonomy_level=2,
|
|
152
|
+
trust_context={"total_actions": 10, "success_rate": 0.95},
|
|
153
|
+
))
|
|
154
|
+
assert result["decision"] == "ALLOW"
|
|
155
|
+
advisory_stages = [s for s in result["pipeline"] if s["status"] == "ADVISORY"]
|
|
156
|
+
assert len(advisory_stages) == 0
|