tracengent 0.1.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.
- tracengent-0.1.0/.gitignore +11 -0
- tracengent-0.1.0/LICENSE +21 -0
- tracengent-0.1.0/PKG-INFO +180 -0
- tracengent-0.1.0/README.md +144 -0
- tracengent-0.1.0/pyproject.toml +68 -0
- tracengent-0.1.0/samples/demo_agent.py +66 -0
- tracengent-0.1.0/src/tracengent/__init__.py +81 -0
- tracengent-0.1.0/src/tracengent/_auth.py +41 -0
- tracengent-0.1.0/src/tracengent/_jwt.py +62 -0
- tracengent-0.1.0/src/tracengent/_permissions.py +15 -0
- tracengent-0.1.0/src/tracengent/_serialization.py +41 -0
- tracengent-0.1.0/src/tracengent/authorization.py +75 -0
- tracengent-0.1.0/src/tracengent/client.py +244 -0
- tracengent-0.1.0/src/tracengent/delegation.py +116 -0
- tracengent-0.1.0/src/tracengent/dispatcher.py +163 -0
- tracengent-0.1.0/src/tracengent/events.py +140 -0
- tracengent-0.1.0/src/tracengent/gateway.py +135 -0
- tracengent-0.1.0/src/tracengent/integrations/__init__.py +8 -0
- tracengent-0.1.0/src/tracengent/integrations/fastapi.py +56 -0
- tracengent-0.1.0/src/tracengent/integrations/flask.py +57 -0
- tracengent-0.1.0/src/tracengent/options.py +96 -0
- tracengent-0.1.0/src/tracengent/py.typed +0 -0
- tracengent-0.1.0/src/tracengent/redaction.py +117 -0
- tracengent-0.1.0/src/tracengent/token_provider.py +89 -0
- tracengent-0.1.0/src/tracengent/tool_gate.py +113 -0
- tracengent-0.1.0/src/tracengent/tool_governance.py +82 -0
- tracengent-0.1.0/src/tracengent/trace.py +226 -0
- tracengent-0.1.0/src/tracengent/trace_context.py +31 -0
- tracengent-0.1.0/src/tracengent/tracking.py +87 -0
- tracengent-0.1.0/tests/conformance/test_conformance.py +171 -0
- tracengent-0.1.0/tests/conformance/wire_contract.json +93 -0
- tracengent-0.1.0/tests/conftest.py +6 -0
- tracengent-0.1.0/tests/integration/test_live_platform.py +64 -0
- tracengent-0.1.0/tests/unit/_helpers.py +40 -0
- tracengent-0.1.0/tests/unit/_jwt_helper.py +14 -0
- tracengent-0.1.0/tests/unit/test_authorization.py +175 -0
- tracengent-0.1.0/tests/unit/test_client.py +88 -0
- tracengent-0.1.0/tests/unit/test_delegation.py +161 -0
- tracengent-0.1.0/tests/unit/test_dispatcher.py +122 -0
- tracengent-0.1.0/tests/unit/test_events.py +60 -0
- tracengent-0.1.0/tests/unit/test_gateway.py +136 -0
- tracengent-0.1.0/tests/unit/test_has_permission.py +50 -0
- tracengent-0.1.0/tests/unit/test_integration_fastapi.py +76 -0
- tracengent-0.1.0/tests/unit/test_integration_flask.py +66 -0
- tracengent-0.1.0/tests/unit/test_jwt.py +73 -0
- tracengent-0.1.0/tests/unit/test_options.py +76 -0
- tracengent-0.1.0/tests/unit/test_permissions.py +23 -0
- tracengent-0.1.0/tests/unit/test_redaction.py +99 -0
- tracengent-0.1.0/tests/unit/test_serialization.py +49 -0
- tracengent-0.1.0/tests/unit/test_token_provider.py +92 -0
- tracengent-0.1.0/tests/unit/test_tool_gate.py +112 -0
- tracengent-0.1.0/tests/unit/test_trace.py +146 -0
- tracengent-0.1.0/tests/unit/test_track_llm.py +81 -0
- tracengent-0.1.0/tests/unit/test_tracking.py +60 -0
tracengent-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Besar Kutleshi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tracengent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: First-party Python SDK for Tracengent: instrument and govern agents (events, decision traces, authorization, tool governance, delegation).
|
|
5
|
+
Project-URL: Homepage, https://github.com/besarkutleshi/agentgov
|
|
6
|
+
Project-URL: Source, https://github.com/besarkutleshi/agentgov
|
|
7
|
+
Project-URL: Repository, https://github.com/besarkutleshi/agentgov.git
|
|
8
|
+
Author: Besar Kutleshi
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,authorization,governance,llm,observability,tracengent
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: typing-extensions>=4.7; python_version < '3.11'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
26
|
+
Requires-Dist: flask>=3.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
31
|
+
Provides-Extra: fastapi
|
|
32
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
33
|
+
Provides-Extra: flask
|
|
34
|
+
Requires-Dist: flask>=3.0; extra == 'flask'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Tracengent — Python SDK
|
|
38
|
+
|
|
39
|
+
First-party Python SDK for [Tracengent](../../phases/phase-10-multi-language-sdks.md): instrument and
|
|
40
|
+
govern agents written in Python with parity to the .NET SDK ([`src/Tracengent.Sdk`](../../src/Tracengent.Sdk/)).
|
|
41
|
+
|
|
42
|
+
> **Status: feature-complete (Phase 10).** Full parity with the .NET SDK across telemetry, traces,
|
|
43
|
+
> authorization, tool governance, and delegation. The reference implementation is the .NET SDK; the
|
|
44
|
+
> contract is [§3 of the phase doc](../../phases/phase-10-multi-language-sdks.md#3-the-shared-wire-contract-the-spec-all-five-sdks-implement).
|
|
45
|
+
|
|
46
|
+
## Milestone status
|
|
47
|
+
|
|
48
|
+
| Milestone | Capability | State |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| **M1** | Options + validation, JWT/permission helpers, JSON redactor (pure logic) | ✅ done |
|
|
51
|
+
| **M2** | Event model, token provider, background dispatcher, `record` | ✅ done |
|
|
52
|
+
| **M3** | Decision traces + auto-tracking + ambient context + `authorize`/`ensure_authorized` | ✅ done |
|
|
53
|
+
| **M4** | `has_permission` (token-local) + framework bindings (FastAPI/Flask) | ✅ done |
|
|
54
|
+
| **M5** | Tool governance: egress gate, MCP/function gate, redaction | ✅ done |
|
|
55
|
+
| **M6** | Delegation: delegated session + attenuation (A2A) | ✅ done |
|
|
56
|
+
| **M7** | Packaging + sample + conformance + (gated) integration tests | ✅ done |
|
|
57
|
+
|
|
58
|
+
**Tests:** 152 unit + 7 conformance, 3 gated integration tests (`pytest -m integration`). The
|
|
59
|
+
conformance suite replays the shared [`wire_contract.json`](tests/conformance/wire_contract.json)
|
|
60
|
+
fixture and asserts the SDK's request shapes match the contract byte-for-byte (modulo volatile
|
|
61
|
+
ids/timestamps) — this same fixture is vendored into every Tracengent SDK. A runnable end-to-end
|
|
62
|
+
example lives in [`samples/demo_agent.py`](samples/demo_agent.py).
|
|
63
|
+
|
|
64
|
+
## What works today (M1–M3)
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from tracengent import TracengentClient, TracengentOptions, LlmCallResult, Money
|
|
68
|
+
|
|
69
|
+
opts = TracengentOptions.from_env(agent_id="travel-agent")
|
|
70
|
+
with TracengentClient(opts) as client:
|
|
71
|
+
# Open a decision trace — groups steps, tool calls and actions into one timeline.
|
|
72
|
+
with client.begin_trace("book a trip to Lisbon", actor_user_id="u_123") as trace:
|
|
73
|
+
trace.step("decided to search flights")
|
|
74
|
+
|
|
75
|
+
# Wrap tool calls / actions — timed and recorded automatically.
|
|
76
|
+
flights = trace.track_tool_call("search-flights", lambda: search(...))
|
|
77
|
+
|
|
78
|
+
# Govern an LLM call: reserve token budget before, reconcile actual usage after.
|
|
79
|
+
answer = trace.track_llm_call(
|
|
80
|
+
"summarize",
|
|
81
|
+
lambda: LlmCallResult(value=call_model(...), tokens=900, model="gpt"),
|
|
82
|
+
estimated_tokens=1000,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Imperative authorization for a sensitive action (raises if denied).
|
|
86
|
+
# Send the spend's currency too — the platform converts it into the policy/budget
|
|
87
|
+
# currency before enforcing limits, so a USD charge is checked against a EUR cap.
|
|
88
|
+
client.ensure_authorized("payments:refund", {"amount": 280, "currency": "USD"})
|
|
89
|
+
trace.track_action("refund", lambda: do_refund(...), cost=Money.eur(280))
|
|
90
|
+
# leaving the trace flushes its completion; leaving the client flushes remaining events.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Outbound HTTP can be auto-recorded as `tool_call` events by routing it through a tracked client:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from tracengent import tracked_client
|
|
97
|
+
|
|
98
|
+
http = tracked_client(client, base_url="https://api.example.com")
|
|
99
|
+
http.get("/things") # recorded as a tool_call, attached to the active trace
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Declarative gating for web-hosted agents (the equivalent of .NET's `[RequireAgentPermission]`):
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from fastapi import FastAPI, Depends
|
|
106
|
+
from tracengent.integrations.fastapi import install_tracengent, require_agent_permission
|
|
107
|
+
|
|
108
|
+
app = FastAPI()
|
|
109
|
+
install_tracengent(app, client)
|
|
110
|
+
|
|
111
|
+
@app.post("/refunds", dependencies=[Depends(require_agent_permission("payments:refund"))])
|
|
112
|
+
def refund(): ... # 403s automatically when the agent lacks the permission
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A Flask decorator (`tracengent.integrations.flask`) and a token-local coarse check
|
|
116
|
+
(`client.has_permission("flights:book")`, no round-trip) are also available.
|
|
117
|
+
|
|
118
|
+
**Tool governance (Phase 8).** Govern outbound tool calls the org doesn't own — block, hold for
|
|
119
|
+
approval, or redact before the request leaves:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from tracengent import governed_client
|
|
123
|
+
|
|
124
|
+
# Every request through this client is authorized via /v1/tool-authorize first.
|
|
125
|
+
tools = governed_client(client, base_url="https://api.stripe.com")
|
|
126
|
+
tools.post("/v1/refunds", json={"amount": 280, "card": "4242"})
|
|
127
|
+
# -> denied/held → raises; allowed → forwarded with any redactions applied.
|
|
128
|
+
|
|
129
|
+
# Non-HTTP tools (MCP tools/call, LLM function calls):
|
|
130
|
+
client.tools.guard_function("transfer_funds", {"to": "...", "amount": 50}, invoke=do_transfer)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Delegation / agent-to-agent (Phase 5).** Act under a delegated token and attenuate authority for a
|
|
134
|
+
downstream agent:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
session = client.begin_delegated_task(delegated_token) # token granted out of band
|
|
138
|
+
session.authorize("flights:book", {"amount": 280}) # intersects base policy with the grant
|
|
139
|
+
|
|
140
|
+
# Mint a narrower child token to hand to another agent for an A2A hop:
|
|
141
|
+
child = session.attenuate_for_call("agt_hotel", ["hotels:book"], max_amount=500)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Pure helpers are also exported directly:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from tracengent import permission_matches, Redaction, apply_redactions
|
|
148
|
+
|
|
149
|
+
permission_matches("flights:*", "flights:book") # True
|
|
150
|
+
apply_redactions('{"amount":100,"card":"4242"}', [Redaction("$.card", "mask")])
|
|
151
|
+
# -> '{"amount":100,"card":"***REDACTED***"}'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Requirements
|
|
155
|
+
|
|
156
|
+
- Python 3.10+
|
|
157
|
+
- `httpx` (HTTP transport)
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
cd sdk/python
|
|
163
|
+
pip install -e ".[dev]"
|
|
164
|
+
pytest -q # unit tests
|
|
165
|
+
ruff check . # lint
|
|
166
|
+
mypy # type-check
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
CI runs lint + type-check + tests on every push (see [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml), job `sdk-python`).
|
|
170
|
+
|
|
171
|
+
## Environment variables
|
|
172
|
+
|
|
173
|
+
| Var | Meaning |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `TRACENGENT_URL` | Base URL of the Tracengent API |
|
|
176
|
+
| `TRACENGENT_CLIENT_ID` | Agent OAuth client id (`key_...`) — preferred |
|
|
177
|
+
| `TRACENGENT_CLIENT_SECRET` | Agent OAuth client secret (`ags_...`) |
|
|
178
|
+
| `TRACENGENT_INGEST_KEY` | Legacy org ingest key (`agk_...`) — fallback |
|
|
179
|
+
| `TRACENGENT_AGENT_ID` | Logical agent key reported with events |
|
|
180
|
+
| `TRACENGENT_ENVIRONMENT` | Environment tag (default `production`) |
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Tracengent — Python SDK
|
|
2
|
+
|
|
3
|
+
First-party Python SDK for [Tracengent](../../phases/phase-10-multi-language-sdks.md): instrument and
|
|
4
|
+
govern agents written in Python with parity to the .NET SDK ([`src/Tracengent.Sdk`](../../src/Tracengent.Sdk/)).
|
|
5
|
+
|
|
6
|
+
> **Status: feature-complete (Phase 10).** Full parity with the .NET SDK across telemetry, traces,
|
|
7
|
+
> authorization, tool governance, and delegation. The reference implementation is the .NET SDK; the
|
|
8
|
+
> contract is [§3 of the phase doc](../../phases/phase-10-multi-language-sdks.md#3-the-shared-wire-contract-the-spec-all-five-sdks-implement).
|
|
9
|
+
|
|
10
|
+
## Milestone status
|
|
11
|
+
|
|
12
|
+
| Milestone | Capability | State |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| **M1** | Options + validation, JWT/permission helpers, JSON redactor (pure logic) | ✅ done |
|
|
15
|
+
| **M2** | Event model, token provider, background dispatcher, `record` | ✅ done |
|
|
16
|
+
| **M3** | Decision traces + auto-tracking + ambient context + `authorize`/`ensure_authorized` | ✅ done |
|
|
17
|
+
| **M4** | `has_permission` (token-local) + framework bindings (FastAPI/Flask) | ✅ done |
|
|
18
|
+
| **M5** | Tool governance: egress gate, MCP/function gate, redaction | ✅ done |
|
|
19
|
+
| **M6** | Delegation: delegated session + attenuation (A2A) | ✅ done |
|
|
20
|
+
| **M7** | Packaging + sample + conformance + (gated) integration tests | ✅ done |
|
|
21
|
+
|
|
22
|
+
**Tests:** 152 unit + 7 conformance, 3 gated integration tests (`pytest -m integration`). The
|
|
23
|
+
conformance suite replays the shared [`wire_contract.json`](tests/conformance/wire_contract.json)
|
|
24
|
+
fixture and asserts the SDK's request shapes match the contract byte-for-byte (modulo volatile
|
|
25
|
+
ids/timestamps) — this same fixture is vendored into every Tracengent SDK. A runnable end-to-end
|
|
26
|
+
example lives in [`samples/demo_agent.py`](samples/demo_agent.py).
|
|
27
|
+
|
|
28
|
+
## What works today (M1–M3)
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from tracengent import TracengentClient, TracengentOptions, LlmCallResult, Money
|
|
32
|
+
|
|
33
|
+
opts = TracengentOptions.from_env(agent_id="travel-agent")
|
|
34
|
+
with TracengentClient(opts) as client:
|
|
35
|
+
# Open a decision trace — groups steps, tool calls and actions into one timeline.
|
|
36
|
+
with client.begin_trace("book a trip to Lisbon", actor_user_id="u_123") as trace:
|
|
37
|
+
trace.step("decided to search flights")
|
|
38
|
+
|
|
39
|
+
# Wrap tool calls / actions — timed and recorded automatically.
|
|
40
|
+
flights = trace.track_tool_call("search-flights", lambda: search(...))
|
|
41
|
+
|
|
42
|
+
# Govern an LLM call: reserve token budget before, reconcile actual usage after.
|
|
43
|
+
answer = trace.track_llm_call(
|
|
44
|
+
"summarize",
|
|
45
|
+
lambda: LlmCallResult(value=call_model(...), tokens=900, model="gpt"),
|
|
46
|
+
estimated_tokens=1000,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Imperative authorization for a sensitive action (raises if denied).
|
|
50
|
+
# Send the spend's currency too — the platform converts it into the policy/budget
|
|
51
|
+
# currency before enforcing limits, so a USD charge is checked against a EUR cap.
|
|
52
|
+
client.ensure_authorized("payments:refund", {"amount": 280, "currency": "USD"})
|
|
53
|
+
trace.track_action("refund", lambda: do_refund(...), cost=Money.eur(280))
|
|
54
|
+
# leaving the trace flushes its completion; leaving the client flushes remaining events.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Outbound HTTP can be auto-recorded as `tool_call` events by routing it through a tracked client:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from tracengent import tracked_client
|
|
61
|
+
|
|
62
|
+
http = tracked_client(client, base_url="https://api.example.com")
|
|
63
|
+
http.get("/things") # recorded as a tool_call, attached to the active trace
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Declarative gating for web-hosted agents (the equivalent of .NET's `[RequireAgentPermission]`):
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from fastapi import FastAPI, Depends
|
|
70
|
+
from tracengent.integrations.fastapi import install_tracengent, require_agent_permission
|
|
71
|
+
|
|
72
|
+
app = FastAPI()
|
|
73
|
+
install_tracengent(app, client)
|
|
74
|
+
|
|
75
|
+
@app.post("/refunds", dependencies=[Depends(require_agent_permission("payments:refund"))])
|
|
76
|
+
def refund(): ... # 403s automatically when the agent lacks the permission
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A Flask decorator (`tracengent.integrations.flask`) and a token-local coarse check
|
|
80
|
+
(`client.has_permission("flights:book")`, no round-trip) are also available.
|
|
81
|
+
|
|
82
|
+
**Tool governance (Phase 8).** Govern outbound tool calls the org doesn't own — block, hold for
|
|
83
|
+
approval, or redact before the request leaves:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from tracengent import governed_client
|
|
87
|
+
|
|
88
|
+
# Every request through this client is authorized via /v1/tool-authorize first.
|
|
89
|
+
tools = governed_client(client, base_url="https://api.stripe.com")
|
|
90
|
+
tools.post("/v1/refunds", json={"amount": 280, "card": "4242"})
|
|
91
|
+
# -> denied/held → raises; allowed → forwarded with any redactions applied.
|
|
92
|
+
|
|
93
|
+
# Non-HTTP tools (MCP tools/call, LLM function calls):
|
|
94
|
+
client.tools.guard_function("transfer_funds", {"to": "...", "amount": 50}, invoke=do_transfer)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Delegation / agent-to-agent (Phase 5).** Act under a delegated token and attenuate authority for a
|
|
98
|
+
downstream agent:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
session = client.begin_delegated_task(delegated_token) # token granted out of band
|
|
102
|
+
session.authorize("flights:book", {"amount": 280}) # intersects base policy with the grant
|
|
103
|
+
|
|
104
|
+
# Mint a narrower child token to hand to another agent for an A2A hop:
|
|
105
|
+
child = session.attenuate_for_call("agt_hotel", ["hotels:book"], max_amount=500)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Pure helpers are also exported directly:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from tracengent import permission_matches, Redaction, apply_redactions
|
|
112
|
+
|
|
113
|
+
permission_matches("flights:*", "flights:book") # True
|
|
114
|
+
apply_redactions('{"amount":100,"card":"4242"}', [Redaction("$.card", "mask")])
|
|
115
|
+
# -> '{"amount":100,"card":"***REDACTED***"}'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
- Python 3.10+
|
|
121
|
+
- `httpx` (HTTP transport)
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cd sdk/python
|
|
127
|
+
pip install -e ".[dev]"
|
|
128
|
+
pytest -q # unit tests
|
|
129
|
+
ruff check . # lint
|
|
130
|
+
mypy # type-check
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
CI runs lint + type-check + tests on every push (see [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml), job `sdk-python`).
|
|
134
|
+
|
|
135
|
+
## Environment variables
|
|
136
|
+
|
|
137
|
+
| Var | Meaning |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `TRACENGENT_URL` | Base URL of the Tracengent API |
|
|
140
|
+
| `TRACENGENT_CLIENT_ID` | Agent OAuth client id (`key_...`) — preferred |
|
|
141
|
+
| `TRACENGENT_CLIENT_SECRET` | Agent OAuth client secret (`ags_...`) |
|
|
142
|
+
| `TRACENGENT_INGEST_KEY` | Legacy org ingest key (`agk_...`) — fallback |
|
|
143
|
+
| `TRACENGENT_AGENT_ID` | Logical agent key reported with events |
|
|
144
|
+
| `TRACENGENT_ENVIRONMENT` | Environment tag (default `production`) |
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tracengent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "First-party Python SDK for Tracengent: instrument and govern agents (events, decision traces, authorization, tool governance, delegation)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Besar Kutleshi" }]
|
|
13
|
+
keywords = ["agent", "observability", "governance", "authorization", "llm", "tracengent"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"typing-extensions>=4.7; python_version < '3.11'",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
fastapi = ["fastapi>=0.110"]
|
|
32
|
+
flask = ["flask>=3.0"]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0",
|
|
35
|
+
"pytest-asyncio>=0.23",
|
|
36
|
+
"ruff>=0.5",
|
|
37
|
+
"mypy>=1.10",
|
|
38
|
+
"fastapi>=0.110",
|
|
39
|
+
"flask>=3.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/besarkutleshi/agentgov"
|
|
44
|
+
Source = "https://github.com/besarkutleshi/agentgov"
|
|
45
|
+
Repository = "https://github.com/besarkutleshi/agentgov.git"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/tracengent"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
pythonpath = ["src"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
markers = [
|
|
55
|
+
"integration: end-to-end tests against a live platform (require TRACENGENT_TEST_* env vars)",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
src = ["src", "tests"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
python_version = "3.10"
|
|
67
|
+
strict = true
|
|
68
|
+
files = ["src/tracengent"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""A tiny end-to-end sample agent.
|
|
2
|
+
|
|
3
|
+
Run against a live Tracengent platform with credentials in the environment::
|
|
4
|
+
|
|
5
|
+
export TRACENGENT_URL=http://localhost:8080
|
|
6
|
+
export TRACENGENT_CLIENT_ID=key_...
|
|
7
|
+
export TRACENGENT_CLIENT_SECRET=ags_...
|
|
8
|
+
python samples/demo_agent.py
|
|
9
|
+
|
|
10
|
+
It opens a decision trace, records tool/LLM/action steps, authorizes a sensitive action, and
|
|
11
|
+
governs an external tool call — exercising the same surface the .NET demo agent does.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from tracengent import (
|
|
19
|
+
LlmCallResult,
|
|
20
|
+
Money,
|
|
21
|
+
NotAuthorizedException,
|
|
22
|
+
TracengentClient,
|
|
23
|
+
TracengentOptions,
|
|
24
|
+
governed_client,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
opts = TracengentOptions.from_env(agent_id=os.getenv("TRACENGENT_AGENT_ID", "demo-agent"))
|
|
30
|
+
|
|
31
|
+
with TracengentClient(opts) as client:
|
|
32
|
+
with client.begin_trace("book a trip to Lisbon", actor_user_id="u_demo") as trace:
|
|
33
|
+
trace.step("decided to search for flights")
|
|
34
|
+
|
|
35
|
+
flights = trace.track_tool_call(
|
|
36
|
+
"search-flights", lambda: [{"id": "LIS-JFK", "price": 280}]
|
|
37
|
+
)
|
|
38
|
+
print(f"found {len(flights)} flight(s)")
|
|
39
|
+
|
|
40
|
+
summary = trace.track_llm_call(
|
|
41
|
+
"summarize-options",
|
|
42
|
+
lambda: LlmCallResult(value="One direct flight for €280.", tokens=120, model="gpt"),
|
|
43
|
+
estimated_tokens=200,
|
|
44
|
+
)
|
|
45
|
+
print(f"summary: {summary}")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
client.ensure_authorized("payments:refund", {"amount": 280})
|
|
49
|
+
trace.track_action("charge-card", lambda: None, cost=Money.eur(280))
|
|
50
|
+
print("charged €280")
|
|
51
|
+
except NotAuthorizedException as ex:
|
|
52
|
+
print(f"charge not authorized: {ex.reason}")
|
|
53
|
+
|
|
54
|
+
# Govern an external tool the org doesn't own (raises if denied/held).
|
|
55
|
+
try:
|
|
56
|
+
tools = governed_client(client, base_url="https://api.example.com")
|
|
57
|
+
tools.get("/availability")
|
|
58
|
+
tools.close()
|
|
59
|
+
except Exception as ex: # noqa: BLE001 — demo: surface governance outcomes, don't crash
|
|
60
|
+
print(f"external tool call governed: {ex}")
|
|
61
|
+
|
|
62
|
+
print("done; events flushed")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tracengent — first-party Python SDK for agent observability & governance.
|
|
2
|
+
|
|
3
|
+
Through M3: configuration, the event model, OAuth token management, the background batching
|
|
4
|
+
dispatcher (``record``), decision traces, auto-tracking, and authorization (``authorize`` /
|
|
5
|
+
``ensure_authorized``). Tool governance and delegation arrive in later milestones.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._permissions import permission_matches
|
|
11
|
+
from .authorization import AuthDecision, AuthObligations, NotAuthorizedException
|
|
12
|
+
from .client import TracengentClient, new_id
|
|
13
|
+
from .delegation import DelegatedSession, DelegationException
|
|
14
|
+
from .events import (
|
|
15
|
+
Actor,
|
|
16
|
+
AgentEvent,
|
|
17
|
+
EventStatuses,
|
|
18
|
+
EventTypes,
|
|
19
|
+
LlmCallResult,
|
|
20
|
+
Money,
|
|
21
|
+
Usage,
|
|
22
|
+
)
|
|
23
|
+
from .options import TracengentConfigError, TracengentOptions
|
|
24
|
+
from .redaction import MASK_TOKEN, Redaction
|
|
25
|
+
from .redaction import apply as apply_redactions
|
|
26
|
+
from .gateway import GatewayTransport, governed_client
|
|
27
|
+
from .token_provider import AgentTokenProvider
|
|
28
|
+
from .tool_gate import ToolGate, ToolGuardResult
|
|
29
|
+
from .tool_governance import (
|
|
30
|
+
ToolDecision,
|
|
31
|
+
ToolDeniedException,
|
|
32
|
+
ToolPendingApprovalException,
|
|
33
|
+
)
|
|
34
|
+
from .trace import AgentTrace
|
|
35
|
+
from .trace_context import current_trace
|
|
36
|
+
from .tracking import TrackingTransport, tracked_client
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# config
|
|
42
|
+
"TracengentOptions",
|
|
43
|
+
"TracengentConfigError",
|
|
44
|
+
# client + telemetry
|
|
45
|
+
"TracengentClient",
|
|
46
|
+
"AgentEvent",
|
|
47
|
+
"Money",
|
|
48
|
+
"Usage",
|
|
49
|
+
"Actor",
|
|
50
|
+
"LlmCallResult",
|
|
51
|
+
"EventTypes",
|
|
52
|
+
"EventStatuses",
|
|
53
|
+
"AgentTokenProvider",
|
|
54
|
+
"new_id",
|
|
55
|
+
# traces
|
|
56
|
+
"AgentTrace",
|
|
57
|
+
"current_trace",
|
|
58
|
+
"TrackingTransport",
|
|
59
|
+
"tracked_client",
|
|
60
|
+
# authorization
|
|
61
|
+
"AuthDecision",
|
|
62
|
+
"AuthObligations",
|
|
63
|
+
"NotAuthorizedException",
|
|
64
|
+
# tool governance (Phase 8)
|
|
65
|
+
"ToolDecision",
|
|
66
|
+
"ToolDeniedException",
|
|
67
|
+
"ToolPendingApprovalException",
|
|
68
|
+
"ToolGate",
|
|
69
|
+
"ToolGuardResult",
|
|
70
|
+
"GatewayTransport",
|
|
71
|
+
"governed_client",
|
|
72
|
+
# delegation (Phase 5)
|
|
73
|
+
"DelegatedSession",
|
|
74
|
+
"DelegationException",
|
|
75
|
+
# helpers
|
|
76
|
+
"Redaction",
|
|
77
|
+
"apply_redactions",
|
|
78
|
+
"MASK_TOKEN",
|
|
79
|
+
"permission_matches",
|
|
80
|
+
"__version__",
|
|
81
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Attaches auth to platform requests — mirrors the .NET ``AgentAuthHandler``.
|
|
2
|
+
|
|
3
|
+
A per-agent bearer token (client-credentials) when configured, otherwise the legacy org ingest key
|
|
4
|
+
header. On a 401 it refreshes the token once and retries. Returns ``None`` when the agent is disabled
|
|
5
|
+
so the caller can count a drop instead of spamming the server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .options import TracengentOptions
|
|
13
|
+
from .token_provider import AgentTokenProvider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentAuth:
|
|
17
|
+
def __init__(self, options: TracengentOptions, tokens: AgentTokenProvider) -> None:
|
|
18
|
+
self._o = options
|
|
19
|
+
self._tokens = tokens
|
|
20
|
+
|
|
21
|
+
def send(self, http: httpx.Client, request: httpx.Request) -> httpx.Response | None:
|
|
22
|
+
"""Send ``request`` with auth applied. ``None`` means the agent is disabled (treat as a drop)."""
|
|
23
|
+
if self._o.uses_client_credentials:
|
|
24
|
+
token = self._tokens.get_token()
|
|
25
|
+
if token is None:
|
|
26
|
+
return None # disabled — short-circuit locally
|
|
27
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
28
|
+
response = http.send(request)
|
|
29
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
30
|
+
self._tokens.invalidate()
|
|
31
|
+
retry = self._tokens.get_token()
|
|
32
|
+
if retry is not None:
|
|
33
|
+
response.close()
|
|
34
|
+
request.headers["Authorization"] = f"Bearer {retry}"
|
|
35
|
+
return http.send(request)
|
|
36
|
+
return response
|
|
37
|
+
|
|
38
|
+
# Legacy: org ingest key.
|
|
39
|
+
if self._o.ingest_key:
|
|
40
|
+
request.headers["X-Ingest-Key"] = self._o.ingest_key
|
|
41
|
+
return http.send(request)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Decode claims from a JWT *payload* (base64url) without a JWT library or signature validation.
|
|
2
|
+
|
|
3
|
+
Mirrors the .NET ``JwtPayload`` helper and ``AgentTokenProvider.DecodePermissions``. This is used
|
|
4
|
+
only for coarse, local hints (e.g. ``has_permission``) and to read delegation claims — it never
|
|
5
|
+
validates the signature, so it must not be trusted for security decisions on its own.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _decode_payload(jwt: str | None) -> dict[str, Any] | None:
|
|
17
|
+
"""Base64url-decode the payload (second) segment of a JWT and parse it as a JSON object."""
|
|
18
|
+
if not jwt:
|
|
19
|
+
return None
|
|
20
|
+
parts = jwt.split(".")
|
|
21
|
+
if len(parts) < 2:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
segment = parts[1]
|
|
25
|
+
# Restore base64url -> base64 and pad to a multiple of 4 (Python is strict about padding).
|
|
26
|
+
padded = segment + "=" * (-len(segment) % 4)
|
|
27
|
+
raw = base64.urlsafe_b64decode(padded)
|
|
28
|
+
obj = json.loads(raw)
|
|
29
|
+
except (binascii.Error, ValueError, UnicodeDecodeError):
|
|
30
|
+
return None
|
|
31
|
+
return obj if isinstance(obj, dict) else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _as_str_list(value: Any) -> list[str]:
|
|
35
|
+
"""Coerce a claim that may be an array of strings or a single string into a list[str]."""
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [s for s in value if isinstance(s, str) and s]
|
|
38
|
+
if isinstance(value, str) and value:
|
|
39
|
+
return [value]
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_string(jwt: str | None, claim: str) -> str | None:
|
|
44
|
+
"""Read a string claim from the payload; ``None`` if absent or not a string."""
|
|
45
|
+
payload = _decode_payload(jwt)
|
|
46
|
+
if payload is None:
|
|
47
|
+
return None
|
|
48
|
+
value = payload.get(claim)
|
|
49
|
+
return value if isinstance(value, str) else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_string_array(jwt: str | None, claim: str) -> list[str]:
|
|
53
|
+
"""Read a claim as a list of strings (accepts a single string or an array); empty if absent."""
|
|
54
|
+
payload = _decode_payload(jwt)
|
|
55
|
+
if payload is None:
|
|
56
|
+
return []
|
|
57
|
+
return _as_str_list(payload.get(claim))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode_permissions(jwt: str | None) -> list[str]:
|
|
61
|
+
"""Decode the ``permissions`` claim (array or single string) for coarse local checks."""
|
|
62
|
+
return read_string_array(jwt, "permissions")
|