agp-sdk 0.5.0__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.
- agp_sdk-0.5.0/.gitignore +7 -0
- agp_sdk-0.5.0/PKG-INFO +25 -0
- agp_sdk-0.5.0/agp/__init__.py +79 -0
- agp_sdk-0.5.0/agp/client.py +124 -0
- agp_sdk-0.5.0/agp/decision.py +181 -0
- agp_sdk-0.5.0/agp/exceptions.py +129 -0
- agp_sdk-0.5.0/agp/execution.py +186 -0
- agp_sdk-0.5.0/agp/http.py +199 -0
- agp_sdk-0.5.0/agp/models.py +257 -0
- agp_sdk-0.5.0/agp/registry.py +330 -0
- agp_sdk-0.5.0/agp/session.py +306 -0
- agp_sdk-0.5.0/pyproject.toml +45 -0
- agp_sdk-0.5.0/tests/conftest.py +14 -0
- agp_sdk-0.5.0/tests/test_session.py +186 -0
agp_sdk-0.5.0/.gitignore
ADDED
agp_sdk-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agp-sdk
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Python SDK for the Agent Governance Protocol
|
|
5
|
+
Project-URL: Homepage, https://agp.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/cunardai/agp
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/cunardai/agp/issues
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Keywords: accountability,agents,agp,ai,compliance,governance
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx>=0.27.0
|
|
20
|
+
Requires-Dist: pydantic>=2.7.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AGP Python SDK — Agent Governance Protocol
|
|
3
|
+
|
|
4
|
+
https://agp.dev
|
|
5
|
+
"""
|
|
6
|
+
from .client import AGPClient
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
AGPApprovalRequiredError,
|
|
9
|
+
AGPBadRequestError,
|
|
10
|
+
AGPConflictError,
|
|
11
|
+
AGPConnectionError,
|
|
12
|
+
AGPError,
|
|
13
|
+
AGPNotFoundError,
|
|
14
|
+
AGPPolicyDeniedError,
|
|
15
|
+
AGPServerError,
|
|
16
|
+
AGPTokenExpiredError,
|
|
17
|
+
AGPTokenRevokedError,
|
|
18
|
+
AGPUnprocessableError,
|
|
19
|
+
)
|
|
20
|
+
from .models import (
|
|
21
|
+
ActionEnvelope,
|
|
22
|
+
ApprovalArtifact,
|
|
23
|
+
AsyncOperation,
|
|
24
|
+
AttestedContext,
|
|
25
|
+
CapabilityToken,
|
|
26
|
+
DecisionRecord,
|
|
27
|
+
DelegationChain,
|
|
28
|
+
EscalationNotice,
|
|
29
|
+
EvidenceBundle,
|
|
30
|
+
EventLedgerRecord,
|
|
31
|
+
ExecutionReceipt,
|
|
32
|
+
GovernanceAttestation,
|
|
33
|
+
LiabilityBinding,
|
|
34
|
+
PagedResult,
|
|
35
|
+
PolicyDecision,
|
|
36
|
+
PolicySet,
|
|
37
|
+
RevocationNotice,
|
|
38
|
+
SkillManifest,
|
|
39
|
+
Task,
|
|
40
|
+
)
|
|
41
|
+
from .session import TaskSession
|
|
42
|
+
|
|
43
|
+
__version__ = "0.5.0"
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AGPClient",
|
|
46
|
+
"TaskSession",
|
|
47
|
+
# Exceptions
|
|
48
|
+
"AGPError",
|
|
49
|
+
"AGPNotFoundError",
|
|
50
|
+
"AGPBadRequestError",
|
|
51
|
+
"AGPConflictError",
|
|
52
|
+
"AGPUnprocessableError",
|
|
53
|
+
"AGPPolicyDeniedError",
|
|
54
|
+
"AGPTokenRevokedError",
|
|
55
|
+
"AGPTokenExpiredError",
|
|
56
|
+
"AGPApprovalRequiredError",
|
|
57
|
+
"AGPConnectionError",
|
|
58
|
+
"AGPServerError",
|
|
59
|
+
# Models
|
|
60
|
+
"Task",
|
|
61
|
+
"LiabilityBinding",
|
|
62
|
+
"CapabilityToken",
|
|
63
|
+
"DelegationChain",
|
|
64
|
+
"SkillManifest",
|
|
65
|
+
"PolicySet",
|
|
66
|
+
"RevocationNotice",
|
|
67
|
+
"AttestedContext",
|
|
68
|
+
"EvidenceBundle",
|
|
69
|
+
"DecisionRecord",
|
|
70
|
+
"PolicyDecision",
|
|
71
|
+
"ApprovalArtifact",
|
|
72
|
+
"EscalationNotice",
|
|
73
|
+
"ActionEnvelope",
|
|
74
|
+
"ExecutionReceipt",
|
|
75
|
+
"EventLedgerRecord",
|
|
76
|
+
"GovernanceAttestation",
|
|
77
|
+
"AsyncOperation",
|
|
78
|
+
"PagedResult",
|
|
79
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AGP SDK — AGPClient
|
|
3
|
+
|
|
4
|
+
Top-level entry point. Composes RegistryClient, DecisionClient,
|
|
5
|
+
ExecutionClient, and TaskSession into a single object.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from agp import AGPClient
|
|
9
|
+
|
|
10
|
+
client = AGPClient("http://localhost:8099")
|
|
11
|
+
|
|
12
|
+
# High-level session API
|
|
13
|
+
with client.task_session(
|
|
14
|
+
principal_id="my-agent",
|
|
15
|
+
requested_outcome="analyse sentiment",
|
|
16
|
+
risk_tier="low",
|
|
17
|
+
) as session:
|
|
18
|
+
session.bind(sponsoring_entity="acme", accountable_owner="cto@acme.com",
|
|
19
|
+
jurisdiction="EU")
|
|
20
|
+
session.decide(agent_id="my-agent", selected_action="analyse",
|
|
21
|
+
rationale="safe", uncertainty_score=0.05)
|
|
22
|
+
session.evaluate(verdict="allow")
|
|
23
|
+
receipt = session.execute(agent_id="my-agent", tool_id="nlp",
|
|
24
|
+
operation={}, capability_token_ref="cap_xxx")
|
|
25
|
+
|
|
26
|
+
# Low-level direct API
|
|
27
|
+
task = client.registry.create_task(...)
|
|
28
|
+
decision = client.decision.create_decision(...)
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from .decision import DecisionClient
|
|
35
|
+
from .execution import ExecutionClient
|
|
36
|
+
from .http import Transport
|
|
37
|
+
from .models import RiskTier, Task
|
|
38
|
+
from .registry import RegistryClient
|
|
39
|
+
from .session import TaskSession
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AGPClient:
|
|
43
|
+
"""
|
|
44
|
+
Main AGP client. Thread-safe for concurrent use.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
base_url: URL of the AGP server (e.g. "http://localhost:8099")
|
|
48
|
+
timeout: HTTP request timeout in seconds (default 30)
|
|
49
|
+
headers: Additional headers to send with every request
|
|
50
|
+
(e.g. {"Authorization": "Bearer <token>"})
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
base_url: str,
|
|
56
|
+
*,
|
|
57
|
+
timeout: float = 30.0,
|
|
58
|
+
headers: dict[str, str] | None = None,
|
|
59
|
+
):
|
|
60
|
+
self._transport = Transport(base_url, timeout=timeout, headers=headers)
|
|
61
|
+
self.registry = RegistryClient(self._transport)
|
|
62
|
+
self.decision = DecisionClient(self._transport)
|
|
63
|
+
self.execution = ExecutionClient(self._transport)
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
self._transport.close()
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> "AGPClient":
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *_: Any) -> None:
|
|
72
|
+
self.close()
|
|
73
|
+
|
|
74
|
+
# ── TaskSession factory ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def task_session(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
principal_id: str,
|
|
80
|
+
requested_outcome: str,
|
|
81
|
+
risk_tier: RiskTier,
|
|
82
|
+
expires_at: str | None = None,
|
|
83
|
+
sponsoring_entity: str | None = None,
|
|
84
|
+
governance_requirements: dict[str, Any] | None = None,
|
|
85
|
+
jurisdictions: list[str] | None = None,
|
|
86
|
+
regulatory_frameworks: list[str] | None = None,
|
|
87
|
+
idempotency_key: str | None = None,
|
|
88
|
+
) -> TaskSession:
|
|
89
|
+
"""
|
|
90
|
+
Create a new task and return a TaskSession bound to it.
|
|
91
|
+
|
|
92
|
+
The session can be used as a context manager — on unhandled exception
|
|
93
|
+
it will attempt to mark the task FAILED.
|
|
94
|
+
"""
|
|
95
|
+
task = self.registry.create_task(
|
|
96
|
+
principal_id=principal_id,
|
|
97
|
+
requested_outcome=requested_outcome,
|
|
98
|
+
risk_tier=risk_tier,
|
|
99
|
+
expires_at=expires_at,
|
|
100
|
+
sponsoring_entity=sponsoring_entity,
|
|
101
|
+
governance_requirements=governance_requirements,
|
|
102
|
+
jurisdictions=jurisdictions,
|
|
103
|
+
regulatory_frameworks=regulatory_frameworks,
|
|
104
|
+
idempotency_key=idempotency_key,
|
|
105
|
+
)
|
|
106
|
+
return TaskSession(
|
|
107
|
+
registry=self.registry,
|
|
108
|
+
decision=self.decision,
|
|
109
|
+
execution=self.execution,
|
|
110
|
+
task=task,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def resume_session(self, task_id: str) -> TaskSession:
|
|
114
|
+
"""
|
|
115
|
+
Resume a TaskSession for an existing task (e.g. after a restart
|
|
116
|
+
or when picking up an APPROVAL_PENDING task from a queue).
|
|
117
|
+
"""
|
|
118
|
+
task = self.registry.get_task(task_id)
|
|
119
|
+
return TaskSession(
|
|
120
|
+
registry=self.registry,
|
|
121
|
+
decision=self.decision,
|
|
122
|
+
execution=self.execution,
|
|
123
|
+
task=task,
|
|
124
|
+
)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AGP SDK — Decision API client
|
|
3
|
+
|
|
4
|
+
Wraps: /agp/decision/contexts, /evidence-bundles, /decisions,
|
|
5
|
+
/policy-evaluations, /approvals, /escalations
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .http import Transport, new_idempotency_key
|
|
13
|
+
from .models import (
|
|
14
|
+
ApprovalArtifact, ApprovalDecision, AttestedContext, DecisionRecord,
|
|
15
|
+
EscalationNotice, EvidenceBundle, PolicyDecision, Verdict,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _now() -> str:
|
|
20
|
+
return datetime.now(timezone.utc).isoformat()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DecisionClient:
|
|
24
|
+
def __init__(self, transport: Transport):
|
|
25
|
+
self._t = transport
|
|
26
|
+
|
|
27
|
+
# ── Attested Contexts ──────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def create_context(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
task_id: str,
|
|
33
|
+
trust_class: str,
|
|
34
|
+
idempotency_key: str | None = None,
|
|
35
|
+
**extra: Any,
|
|
36
|
+
) -> AttestedContext:
|
|
37
|
+
body = {"task_id": task_id, "trust_class": trust_class,
|
|
38
|
+
"created_at": _now(), **extra}
|
|
39
|
+
raw = self._t.post("/agp/decision/contexts", body,
|
|
40
|
+
idempotency_key=idempotency_key or new_idempotency_key())
|
|
41
|
+
return AttestedContext.model_validate(raw)
|
|
42
|
+
|
|
43
|
+
def get_context(self, context_id: str) -> AttestedContext:
|
|
44
|
+
return AttestedContext.model_validate(
|
|
45
|
+
self._t.get(f"/agp/decision/contexts/{context_id}")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# ── Evidence Bundles ───────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def create_evidence_bundle(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
task_id: str,
|
|
54
|
+
items: list[dict[str, Any]],
|
|
55
|
+
idempotency_key: str | None = None,
|
|
56
|
+
) -> EvidenceBundle:
|
|
57
|
+
body = {"task_id": task_id, "items": items, "created_at": _now()}
|
|
58
|
+
raw = self._t.post("/agp/decision/evidence-bundles", body,
|
|
59
|
+
idempotency_key=idempotency_key or new_idempotency_key())
|
|
60
|
+
return EvidenceBundle.model_validate(raw)
|
|
61
|
+
|
|
62
|
+
# ── Decisions ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def create_decision(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
task_id: str,
|
|
68
|
+
agent_id: str,
|
|
69
|
+
selected_action: str,
|
|
70
|
+
rationale: str,
|
|
71
|
+
uncertainty_score: float,
|
|
72
|
+
idempotency_key: str | None = None,
|
|
73
|
+
**extra: Any,
|
|
74
|
+
) -> DecisionRecord:
|
|
75
|
+
body = {
|
|
76
|
+
"task_id": task_id,
|
|
77
|
+
"agent_id": agent_id,
|
|
78
|
+
"selected_action": selected_action,
|
|
79
|
+
"rationale": rationale,
|
|
80
|
+
"uncertainty_score": uncertainty_score,
|
|
81
|
+
"created_at": _now(),
|
|
82
|
+
**extra,
|
|
83
|
+
}
|
|
84
|
+
raw = self._t.post("/agp/decision/decisions", body,
|
|
85
|
+
idempotency_key=idempotency_key or new_idempotency_key())
|
|
86
|
+
return DecisionRecord.model_validate(raw)
|
|
87
|
+
|
|
88
|
+
def get_decision(self, decision_id: str) -> DecisionRecord:
|
|
89
|
+
return DecisionRecord.model_validate(
|
|
90
|
+
self._t.get(f"/agp/decision/decisions/{decision_id}")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ── Policy Evaluations ─────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def evaluate_policy(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
task_id: str,
|
|
99
|
+
decision_ref: str,
|
|
100
|
+
verdict: Verdict,
|
|
101
|
+
rationale: str | None = None,
|
|
102
|
+
policy_set_ref: str | None = None,
|
|
103
|
+
jurisdiction: str | None = None,
|
|
104
|
+
matched_rules: list[str] | None = None,
|
|
105
|
+
obligations: list[str] | None = None,
|
|
106
|
+
idempotency_key: str | None = None,
|
|
107
|
+
) -> PolicyDecision:
|
|
108
|
+
body: dict[str, Any] = {
|
|
109
|
+
"task_id": task_id,
|
|
110
|
+
"decision_ref": decision_ref,
|
|
111
|
+
"verdict": verdict,
|
|
112
|
+
"evaluated_at": _now(),
|
|
113
|
+
}
|
|
114
|
+
if rationale:
|
|
115
|
+
body["rationale"] = rationale
|
|
116
|
+
if policy_set_ref:
|
|
117
|
+
body["policy_set_ref"] = policy_set_ref
|
|
118
|
+
if jurisdiction:
|
|
119
|
+
body["jurisdiction"] = jurisdiction
|
|
120
|
+
if matched_rules:
|
|
121
|
+
body["matched_rules"] = matched_rules
|
|
122
|
+
if obligations:
|
|
123
|
+
body["obligations"] = obligations
|
|
124
|
+
raw = self._t.post("/agp/decision/policy-evaluations", body,
|
|
125
|
+
idempotency_key=idempotency_key or new_idempotency_key())
|
|
126
|
+
return PolicyDecision.model_validate(raw)
|
|
127
|
+
|
|
128
|
+
def get_policy_evaluation(self, policy_evaluation_id: str) -> PolicyDecision:
|
|
129
|
+
return PolicyDecision.model_validate(
|
|
130
|
+
self._t.get(f"/agp/decision/policy-evaluations/{policy_evaluation_id}")
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# ── Approvals ──────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def create_approval(
|
|
136
|
+
self,
|
|
137
|
+
*,
|
|
138
|
+
task_id: str,
|
|
139
|
+
approver_id: str,
|
|
140
|
+
decision: ApprovalDecision,
|
|
141
|
+
notes: str | None = None,
|
|
142
|
+
idempotency_key: str | None = None,
|
|
143
|
+
) -> ApprovalArtifact:
|
|
144
|
+
body: dict[str, Any] = {
|
|
145
|
+
"task_id": task_id,
|
|
146
|
+
"approver_id": approver_id,
|
|
147
|
+
"decision": decision,
|
|
148
|
+
"approved_at": _now(),
|
|
149
|
+
}
|
|
150
|
+
if notes:
|
|
151
|
+
body["notes"] = notes
|
|
152
|
+
raw = self._t.post("/agp/decision/approvals", body,
|
|
153
|
+
idempotency_key=idempotency_key or new_idempotency_key())
|
|
154
|
+
return ApprovalArtifact.model_validate(raw)
|
|
155
|
+
|
|
156
|
+
def get_approval(self, approval_id: str) -> ApprovalArtifact:
|
|
157
|
+
return ApprovalArtifact.model_validate(
|
|
158
|
+
self._t.get(f"/agp/decision/approvals/{approval_id}")
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ── Escalations ────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def escalate(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
task_id: str,
|
|
167
|
+
raised_by: str,
|
|
168
|
+
concern_type: str,
|
|
169
|
+
description: str,
|
|
170
|
+
idempotency_key: str | None = None,
|
|
171
|
+
) -> EscalationNotice:
|
|
172
|
+
body = {
|
|
173
|
+
"task_id": task_id,
|
|
174
|
+
"raised_by": raised_by,
|
|
175
|
+
"concern_type": concern_type,
|
|
176
|
+
"description": description,
|
|
177
|
+
"raised_at": _now(),
|
|
178
|
+
}
|
|
179
|
+
raw = self._t.post("/agp/decision/escalations", body,
|
|
180
|
+
idempotency_key=idempotency_key or new_idempotency_key())
|
|
181
|
+
return EscalationNotice.model_validate(raw)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AGP SDK — Exception hierarchy
|
|
3
|
+
|
|
4
|
+
Every AGP error response carries:
|
|
5
|
+
{ "error": { "code": "AGP_*", "message": "..." } }
|
|
6
|
+
|
|
7
|
+
This module maps HTTP status codes + AGP error codes to typed exceptions
|
|
8
|
+
so callers can catch specific failure modes without parsing raw JSON.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AGPError(Exception):
|
|
14
|
+
"""Base class for all AGP SDK errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, code: str = "AGP_ERROR",
|
|
17
|
+
http_status: int | None = None, retryable: bool = False):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
self.code = code
|
|
21
|
+
self.http_status = http_status
|
|
22
|
+
self.retryable = retryable
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return f"{type(self).__name__}(code={self.code!r}, message={self.message!r})"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AGPNotFoundError(AGPError):
|
|
29
|
+
"""404 — The requested resource does not exist."""
|
|
30
|
+
def __init__(self, message: str):
|
|
31
|
+
super().__init__(message, code="AGP_NOT_FOUND", http_status=404)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AGPBadRequestError(AGPError):
|
|
35
|
+
"""400 — Request is malformed or missing required fields."""
|
|
36
|
+
def __init__(self, message: str, code: str = "AGP_INVALID_REQUEST"):
|
|
37
|
+
super().__init__(message, code=code, http_status=400)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AGPConflictError(AGPError):
|
|
41
|
+
"""409 — Request conflicts with current resource state."""
|
|
42
|
+
def __init__(self, message: str, code: str = "AGP_CONFLICT"):
|
|
43
|
+
super().__init__(message, code=code, http_status=409)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AGPUnprocessableError(AGPError):
|
|
47
|
+
"""422 — Semantically invalid (e.g. illegal state transition)."""
|
|
48
|
+
def __init__(self, message: str, code: str = "AGP_INVALID_REQUEST"):
|
|
49
|
+
super().__init__(message, code=code, http_status=422)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AGPPolicyDeniedError(AGPUnprocessableError):
|
|
53
|
+
"""422 AGP_POLICY_DENIED — Execution blocked by policy verdict."""
|
|
54
|
+
def __init__(self, message: str):
|
|
55
|
+
super().__init__(message, code="AGP_POLICY_DENIED")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AGPTokenRevokedError(AGPUnprocessableError):
|
|
59
|
+
"""422 AGP_TOKEN_REVOKED — Capability token has been revoked."""
|
|
60
|
+
def __init__(self, message: str):
|
|
61
|
+
super().__init__(message, code="AGP_TOKEN_REVOKED")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AGPTokenExpiredError(AGPUnprocessableError):
|
|
65
|
+
"""422 AGP_TOKEN_EXPIRED — Capability token has expired."""
|
|
66
|
+
def __init__(self, message: str):
|
|
67
|
+
super().__init__(message, code="AGP_TOKEN_EXPIRED")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AGPApprovalRequiredError(AGPUnprocessableError):
|
|
71
|
+
"""422 AGP_APPROVAL_REQUIRED — Human approval artifact missing or pending."""
|
|
72
|
+
def __init__(self, message: str):
|
|
73
|
+
super().__init__(message, code="AGP_APPROVAL_REQUIRED")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AGPServerError(AGPError):
|
|
77
|
+
"""5xx — Server-side error, may be retryable."""
|
|
78
|
+
def __init__(self, message: str, http_status: int = 500):
|
|
79
|
+
super().__init__(message, code="AGP_SERVER_ERROR",
|
|
80
|
+
http_status=http_status, retryable=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AGPConnectionError(AGPError):
|
|
84
|
+
"""Network-level failure reaching the AGP server."""
|
|
85
|
+
def __init__(self, message: str):
|
|
86
|
+
super().__init__(message, code="AGP_CONNECTION_ERROR", retryable=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Error code → exception mapping ────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
_CODE_MAP: dict[str, type[AGPError]] = {
|
|
92
|
+
"AGP_NOT_FOUND": AGPNotFoundError,
|
|
93
|
+
"AGP_POLICY_DENIED": AGPPolicyDeniedError,
|
|
94
|
+
"AGP_TOKEN_REVOKED": AGPTokenRevokedError,
|
|
95
|
+
"AGP_TOKEN_EXPIRED": AGPTokenExpiredError,
|
|
96
|
+
"AGP_APPROVAL_REQUIRED": AGPApprovalRequiredError,
|
|
97
|
+
"AGP_SCOPE_VIOLATION": AGPConflictError,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_STATUS_MAP: dict[int, type[AGPError]] = {
|
|
101
|
+
400: AGPBadRequestError,
|
|
102
|
+
404: AGPNotFoundError,
|
|
103
|
+
409: AGPConflictError,
|
|
104
|
+
422: AGPUnprocessableError,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def raise_for_response(status: int, body: dict) -> None:
|
|
109
|
+
"""Parse an AGP error body and raise the appropriate exception."""
|
|
110
|
+
error = body.get("error", {})
|
|
111
|
+
code = error.get("code", "AGP_ERROR")
|
|
112
|
+
message = error.get("message", f"HTTP {status}")
|
|
113
|
+
|
|
114
|
+
# Specific code takes priority
|
|
115
|
+
if code in _CODE_MAP:
|
|
116
|
+
exc_cls = _CODE_MAP[code]
|
|
117
|
+
if exc_cls is AGPNotFoundError:
|
|
118
|
+
raise exc_cls(message)
|
|
119
|
+
raise exc_cls(message) # type: ignore[call-arg]
|
|
120
|
+
|
|
121
|
+
# Fall back to status code
|
|
122
|
+
exc_cls_by_status = _STATUS_MAP.get(status)
|
|
123
|
+
if exc_cls_by_status:
|
|
124
|
+
raise exc_cls_by_status(message) # type: ignore[call-arg]
|
|
125
|
+
|
|
126
|
+
if status >= 500:
|
|
127
|
+
raise AGPServerError(message, http_status=status)
|
|
128
|
+
|
|
129
|
+
raise AGPError(message, code=code, http_status=status)
|