traceforge-toolkit 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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
"""Governance composition-root facade.
|
|
2
|
+
|
|
3
|
+
Wires the governance collaborator graph and forwards the public API to them
|
|
4
|
+
(monitor, scorer, context builder, shield). No governance logic lives here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from traceforge.governance.assessor import DefaultAssessor
|
|
13
|
+
from traceforge.governance.codec import MetaCodec
|
|
14
|
+
from traceforge.governance.context import ContextBuilder
|
|
15
|
+
from traceforge.governance.monitor import SessionMonitor
|
|
16
|
+
from traceforge.governance.phase1 import Phase1
|
|
17
|
+
from traceforge.governance.registry import SessionRegistry
|
|
18
|
+
from traceforge.governance.scorer import Scorer
|
|
19
|
+
from traceforge.governance.shield import Shield
|
|
20
|
+
from traceforge.governance.results import SessionMeta
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import traceforge.types
|
|
24
|
+
|
|
25
|
+
from traceforge.classify.config import ClassificationEngine
|
|
26
|
+
from traceforge.governance.budget import BudgetTracker
|
|
27
|
+
from traceforge.governance.labeler import GovernanceLabeler
|
|
28
|
+
from traceforge.governance.persistence import SystemStore
|
|
29
|
+
from traceforge.governance.rules import Rule
|
|
30
|
+
from traceforge.governance.state import SessionState
|
|
31
|
+
from traceforge.governance.types import (
|
|
32
|
+
EnrichmentContext,
|
|
33
|
+
ToolCallEvent,
|
|
34
|
+
)
|
|
35
|
+
from traceforge.sdk.gate_policy import GatePolicy
|
|
36
|
+
from traceforge.sdk.gate_types import PostflightVerdict
|
|
37
|
+
from traceforge.sdk.verdict import Verdict
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _import_dotted(dotted_path: str):
|
|
41
|
+
"""Import a callable from a dotted module path (e.g. 'myapp.policies.my_policy')."""
|
|
42
|
+
module_path, _, attr_name = dotted_path.rpartition(".")
|
|
43
|
+
if not module_path:
|
|
44
|
+
raise ImportError(f"Invalid dotted path: {dotted_path!r} (need module.attr)")
|
|
45
|
+
import importlib
|
|
46
|
+
|
|
47
|
+
module = importlib.import_module(module_path)
|
|
48
|
+
return getattr(module, attr_name)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GovernancePipeline:
|
|
52
|
+
"""Composition-root facade for the governance subsystem.
|
|
53
|
+
|
|
54
|
+
Constructs the collaborator object graph (registry, assessor, phase-1,
|
|
55
|
+
codec, context builder, scorer, shield, monitor) and exposes the public API
|
|
56
|
+
by delegating to them:
|
|
57
|
+
|
|
58
|
+
* observation / lifecycle / state → :class:`SessionMonitor` (the single writer)
|
|
59
|
+
* read-only scoring & previews → :class:`Scorer`
|
|
60
|
+
* event → context bridging → :class:`ContextBuilder`
|
|
61
|
+
* enforcement (preflight/postflight) → :class:`Shield`, to which the
|
|
62
|
+
``gate_*`` framework adapters bind at the edge.
|
|
63
|
+
|
|
64
|
+
It holds no governance logic of its own — only wiring and forwards.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
store: "SystemStore",
|
|
70
|
+
labeler: "GovernanceLabeler",
|
|
71
|
+
budget_tracker: "BudgetTracker",
|
|
72
|
+
rules: "list[Rule]",
|
|
73
|
+
engine: "ClassificationEngine",
|
|
74
|
+
project_root: str | None = None,
|
|
75
|
+
policy: "GatePolicy | None" = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
# ── Facade-observable state ──
|
|
78
|
+
self._store = store
|
|
79
|
+
self._project_root = project_root
|
|
80
|
+
self.policy: "GatePolicy | None" = policy
|
|
81
|
+
|
|
82
|
+
# ── Collaborator object graph (composition root; DIP wiring) ──
|
|
83
|
+
self._registry = SessionRegistry(store)
|
|
84
|
+
self._assessor = DefaultAssessor(labeler, rules, engine)
|
|
85
|
+
self._phase1 = Phase1(budget_tracker, labeler)
|
|
86
|
+
self._codec = MetaCodec()
|
|
87
|
+
self._context = ContextBuilder(engine, project_root)
|
|
88
|
+
self._scorer = Scorer(
|
|
89
|
+
self._context,
|
|
90
|
+
self._phase1,
|
|
91
|
+
self._assessor,
|
|
92
|
+
self._registry,
|
|
93
|
+
store,
|
|
94
|
+
self._codec,
|
|
95
|
+
)
|
|
96
|
+
self._shield = Shield(self._registry, lambda: self.policy, self._scorer.score_event)
|
|
97
|
+
self._monitor = SessionMonitor(
|
|
98
|
+
self._context,
|
|
99
|
+
self._phase1,
|
|
100
|
+
self._assessor,
|
|
101
|
+
self._registry,
|
|
102
|
+
store,
|
|
103
|
+
self._codec,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def create(
|
|
108
|
+
cls,
|
|
109
|
+
config: "GovernanceConfig | None" = None,
|
|
110
|
+
*,
|
|
111
|
+
policy: "GatePolicy | None" = None,
|
|
112
|
+
) -> "GovernancePipeline":
|
|
113
|
+
"""Construct a ready-to-use pipeline from config.
|
|
114
|
+
|
|
115
|
+
Usage::
|
|
116
|
+
|
|
117
|
+
from traceforge.governance.pipeline import GovernancePipeline
|
|
118
|
+
from traceforge.sdk import GatePolicy
|
|
119
|
+
|
|
120
|
+
# Zero-config (all defaults)
|
|
121
|
+
pipeline = GovernancePipeline.create()
|
|
122
|
+
|
|
123
|
+
# With gate policy
|
|
124
|
+
policy = GatePolicy().preflight(my_gate)
|
|
125
|
+
pipeline = GovernancePipeline.create(policy=policy)
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
config: GovernanceConfig instance. Defaults to GovernanceConfig()
|
|
129
|
+
(in-memory DB, PII scanning on, no budget caps).
|
|
130
|
+
policy: Optional GatePolicy with registered gates.
|
|
131
|
+
"""
|
|
132
|
+
from pathlib import Path
|
|
133
|
+
|
|
134
|
+
from traceforge.classify.config import get_default_engine
|
|
135
|
+
from traceforge.config.models import GovernanceConfig
|
|
136
|
+
from traceforge.governance.budget import BudgetThresholds, BudgetTracker
|
|
137
|
+
from traceforge.governance.integrity import IntegrityVerifier
|
|
138
|
+
from traceforge.governance.labeler import GovernanceLabeler
|
|
139
|
+
from traceforge.governance.persistence import SystemStore
|
|
140
|
+
from traceforge.governance.rules import parse_rules
|
|
141
|
+
|
|
142
|
+
if config is None:
|
|
143
|
+
config = GovernanceConfig()
|
|
144
|
+
|
|
145
|
+
store = SystemStore(config.db_path or ":memory:")
|
|
146
|
+
engine = get_default_engine()
|
|
147
|
+
|
|
148
|
+
# Rules: custom path or bundled defaults
|
|
149
|
+
if config.rules_path:
|
|
150
|
+
rules_path = Path(config.rules_path)
|
|
151
|
+
else:
|
|
152
|
+
rules_path = (
|
|
153
|
+
Path(__file__).parent.parent / "classify" / "data" / "recommendation_rules.yaml"
|
|
154
|
+
)
|
|
155
|
+
rules = parse_rules(rules_path)
|
|
156
|
+
|
|
157
|
+
# Budget thresholds from config
|
|
158
|
+
thresholds = BudgetThresholds(
|
|
159
|
+
max_tool_calls=config.budget.max_tool_calls,
|
|
160
|
+
max_by_effect=config.budget.max_by_effect,
|
|
161
|
+
max_by_capability=config.budget.max_by_capability,
|
|
162
|
+
max_by_scope=config.budget.max_by_scope,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# PII scanner
|
|
166
|
+
pii_scanner = None
|
|
167
|
+
if config.pii_scanning:
|
|
168
|
+
from traceforge.governance.pii import PIIScanner
|
|
169
|
+
|
|
170
|
+
pii_scanner = PIIScanner()
|
|
171
|
+
|
|
172
|
+
# Content integrity is live by default (opt out via integrity_verification).
|
|
173
|
+
# The verifier is per-event: it derives the repo key from each event's
|
|
174
|
+
# ctx.project_root, so no construction-time repo is needed.
|
|
175
|
+
integrity_verifier = None
|
|
176
|
+
if config.integrity_verification:
|
|
177
|
+
integrity_verifier = IntegrityVerifier(store)
|
|
178
|
+
|
|
179
|
+
instance = cls(
|
|
180
|
+
store=store,
|
|
181
|
+
labeler=GovernanceLabeler(
|
|
182
|
+
pii_scanner=pii_scanner, integrity_verifier=integrity_verifier
|
|
183
|
+
),
|
|
184
|
+
budget_tracker=BudgetTracker(thresholds=thresholds),
|
|
185
|
+
rules=rules,
|
|
186
|
+
engine=engine,
|
|
187
|
+
policy=policy,
|
|
188
|
+
)
|
|
189
|
+
instance._project_root = config.project_root
|
|
190
|
+
return instance
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_config(cls, path=None, *, policy: "GatePolicy | None" = None) -> "GovernancePipeline":
|
|
194
|
+
"""Create a fully-configured pipeline from a traceforge.yaml file.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
path: Path to traceforge.yaml. None uses standard discovery
|
|
198
|
+
(TRACEFORGE_CONFIG env, ./traceforge.yaml, ~/.traceforge/config.yaml).
|
|
199
|
+
policy: GatePolicy override. If None, loads the preflight gate from config's
|
|
200
|
+
dotted import path (governance.tool_preflight_gate) into a new policy.
|
|
201
|
+
|
|
202
|
+
Usage:
|
|
203
|
+
pipeline = Pipeline.from_config()
|
|
204
|
+
pipeline = Pipeline.from_config(policy=my_policy)
|
|
205
|
+
"""
|
|
206
|
+
from traceforge.config.loader import load_config_from_path
|
|
207
|
+
|
|
208
|
+
config = load_config_from_path(path)
|
|
209
|
+
|
|
210
|
+
instance = cls.create(config.governance)
|
|
211
|
+
|
|
212
|
+
# Resolve policy: explicit arg > config dotted path > config external gate > None
|
|
213
|
+
if policy is not None:
|
|
214
|
+
instance.policy = policy
|
|
215
|
+
elif config.governance.tool_preflight_gate:
|
|
216
|
+
from traceforge.sdk.gate_policy import GatePolicy
|
|
217
|
+
|
|
218
|
+
gate_fn = _import_dotted(config.governance.tool_preflight_gate)
|
|
219
|
+
auto_policy = GatePolicy().preflight(gate_fn)
|
|
220
|
+
instance.policy = auto_policy
|
|
221
|
+
elif config.governance.preflight_gate is not None:
|
|
222
|
+
from traceforge.gate.external import HttpGate, SubprocessGate
|
|
223
|
+
from traceforge.sdk.gate_policy import GatePolicy
|
|
224
|
+
|
|
225
|
+
gate_cfg = config.governance.preflight_gate
|
|
226
|
+
if gate_cfg.type == "http":
|
|
227
|
+
gate_fn = HttpGate(
|
|
228
|
+
endpoint=gate_cfg.endpoint,
|
|
229
|
+
timeout=gate_cfg.timeout,
|
|
230
|
+
fail_open=gate_cfg.fail_open,
|
|
231
|
+
headers=dict(gate_cfg.headers) if gate_cfg.headers else None,
|
|
232
|
+
max_input_bytes=gate_cfg.max_input_bytes,
|
|
233
|
+
)
|
|
234
|
+
else: # gate_cfg.type == "subprocess"
|
|
235
|
+
gate_fn = SubprocessGate(
|
|
236
|
+
command=gate_cfg.command,
|
|
237
|
+
timeout=gate_cfg.timeout,
|
|
238
|
+
fail_open=gate_cfg.fail_open,
|
|
239
|
+
max_input_bytes=gate_cfg.max_input_bytes,
|
|
240
|
+
)
|
|
241
|
+
instance.policy = GatePolicy().preflight(gate_fn)
|
|
242
|
+
|
|
243
|
+
return instance
|
|
244
|
+
|
|
245
|
+
def context_from_session_event(
|
|
246
|
+
self, event: "traceforge.types.SessionEvent"
|
|
247
|
+
) -> "EnrichmentContext":
|
|
248
|
+
"""Bridge a SessionEvent into an EnrichmentContext (delegates to ContextBuilder)."""
|
|
249
|
+
return self._context.from_session_event(event)
|
|
250
|
+
|
|
251
|
+
def enrich_event(self, event: "ToolCallEvent") -> "EnrichmentContext":
|
|
252
|
+
"""Classify a ToolCallEvent into an EnrichmentContext (delegates to ContextBuilder)."""
|
|
253
|
+
return self._context.from_tool_call(event)
|
|
254
|
+
|
|
255
|
+
def score_tool_call(self, payload: dict) -> "EventTrace":
|
|
256
|
+
"""Score a pending tool call against current session state (delegates to Scorer)."""
|
|
257
|
+
return self._scorer.score_tool_call(payload)
|
|
258
|
+
|
|
259
|
+
# ─── Central gate execution helpers ───────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
def _run_preflight(self, trace: "EventTrace", *, session_id: str) -> "Verdict":
|
|
262
|
+
"""Forward to the Shield's preflight enforcement chain."""
|
|
263
|
+
return self._shield.run_preflight(trace, session_id=session_id)
|
|
264
|
+
|
|
265
|
+
def _run_postflight(
|
|
266
|
+
self,
|
|
267
|
+
trace: "EventTrace",
|
|
268
|
+
*,
|
|
269
|
+
session_id: str,
|
|
270
|
+
output: dict | None = None,
|
|
271
|
+
duration_ms: int | None = None,
|
|
272
|
+
error: str | None = None,
|
|
273
|
+
) -> "PostflightVerdict":
|
|
274
|
+
"""Forward to the Shield's postflight enforcement chain."""
|
|
275
|
+
return self._shield.run_postflight(
|
|
276
|
+
trace,
|
|
277
|
+
session_id=session_id,
|
|
278
|
+
output=output,
|
|
279
|
+
duration_ms=duration_ms,
|
|
280
|
+
error=error,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def score_tool_call_event(self, event: "traceforge.types.SessionEvent") -> "SessionMeta":
|
|
284
|
+
"""Score an enriched SessionEvent via the canonical bridge (delegates to Scorer)."""
|
|
285
|
+
return self._scorer.score_tool_call_event(event)
|
|
286
|
+
|
|
287
|
+
def observe_event(self, event: "traceforge.types.SessionEvent") -> "SessionMeta | None":
|
|
288
|
+
"""Observation-path scoring stage — the single writer (delegates to SessionMonitor)."""
|
|
289
|
+
return self._monitor.observe_event(event)
|
|
290
|
+
|
|
291
|
+
def preflight_event(self, ctx: "EnrichmentContext") -> "SessionMeta":
|
|
292
|
+
"""Preview Phase 1/2/3 without persisting state (delegates to Scorer)."""
|
|
293
|
+
return self._scorer.preflight_event(ctx)
|
|
294
|
+
|
|
295
|
+
def get_or_create_state(self, session_id: str) -> "SessionState":
|
|
296
|
+
"""Get or create session state, rehydrating on a miss (delegates to SessionMonitor)."""
|
|
297
|
+
return self._monitor.get_or_create_state(session_id)
|
|
298
|
+
|
|
299
|
+
def process_lifecycle(self, session_id: str, event_kind: str) -> SessionMeta:
|
|
300
|
+
"""Handle session_start/end — Phase 1 only (delegates to SessionMonitor)."""
|
|
301
|
+
return self._monitor.process_lifecycle(session_id, event_kind)
|
|
302
|
+
|
|
303
|
+
def process_event(self, ctx: "EnrichmentContext") -> SessionMeta:
|
|
304
|
+
"""Full mutating pipeline: Phase 1 -> 2 -> 3 (delegates to SessionMonitor)."""
|
|
305
|
+
return self._monitor.process_event(ctx)
|
|
306
|
+
|
|
307
|
+
# ─── Framework gating methods ────────────────────────────────────────────────
|
|
308
|
+
#
|
|
309
|
+
# Each method integrates traceforge into a framework's native blocking mechanism.
|
|
310
|
+
# Session identity is extracted from the framework's own context — no session_id kwarg.
|
|
311
|
+
# Postflight verdicts (SUPPRESS/TERMINATE/REDACT) are enforced via framework-native signals.
|
|
312
|
+
|
|
313
|
+
def _score_and_gate_preflight(self, payload: dict) -> tuple:
|
|
314
|
+
"""Forward to the Shield: score a tool call and run the preflight chain."""
|
|
315
|
+
return self._shield.score_and_gate_preflight(payload)
|
|
316
|
+
|
|
317
|
+
def _enforce_postflight(
|
|
318
|
+
self,
|
|
319
|
+
trace: "EventTrace",
|
|
320
|
+
*,
|
|
321
|
+
session_id: str,
|
|
322
|
+
output: dict | None = None,
|
|
323
|
+
duration_ms: int | None = None,
|
|
324
|
+
error: str | None = None,
|
|
325
|
+
) -> "PostflightVerdict":
|
|
326
|
+
"""Forward to the Shield: observe the completed call, then run postflight."""
|
|
327
|
+
return self._shield.enforce_postflight(
|
|
328
|
+
trace,
|
|
329
|
+
session_id=session_id,
|
|
330
|
+
output=output,
|
|
331
|
+
duration_ms=duration_ms,
|
|
332
|
+
error=error,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _apply_postflight_to_output(pv: "PostflightVerdict", result: str) -> str:
|
|
337
|
+
"""Forward to the Shield's postflight-output transform."""
|
|
338
|
+
return Shield.apply_postflight_to_output(pv, result)
|
|
339
|
+
|
|
340
|
+
def gate_crewai(self) -> None:
|
|
341
|
+
"""Register traceforge into CrewAI's before/after tool_call hooks.
|
|
342
|
+
|
|
343
|
+
Blocking: returns False to CrewAI when preflight returns DENY.
|
|
344
|
+
Session ID: extracted from CrewAI's ctx.crew.fingerprint or generated.
|
|
345
|
+
|
|
346
|
+
WARNING: CrewAI hooks are global. Calling this multiple times registers
|
|
347
|
+
duplicate hooks. Use once per process.
|
|
348
|
+
"""
|
|
349
|
+
if getattr(self, "_crewai_gated", False):
|
|
350
|
+
return
|
|
351
|
+
self._crewai_gated = True
|
|
352
|
+
|
|
353
|
+
from crewai.hooks.decorators import after_tool_call, before_tool_call
|
|
354
|
+
|
|
355
|
+
pipeline = self
|
|
356
|
+
# Bounded trace stash — evicts oldest entries to prevent unbounded growth.
|
|
357
|
+
# Max 1000 pending tool calls is generous for any real CrewAI session.
|
|
358
|
+
_traces: dict[str, "EventTrace"] = {}
|
|
359
|
+
_MAX_PENDING = 1000
|
|
360
|
+
|
|
361
|
+
@before_tool_call
|
|
362
|
+
def _traceforge_hook(ctx):
|
|
363
|
+
sid = getattr(getattr(ctx, "crew", None), "fingerprint", None) or "crewai"
|
|
364
|
+
call_id = getattr(ctx, "tool_call_id", None) or f"{ctx.tool_name}:{id(ctx)}"
|
|
365
|
+
payload = {
|
|
366
|
+
"tool_name": ctx.tool_name,
|
|
367
|
+
"tool_input": ctx.tool_input,
|
|
368
|
+
"session_id": sid,
|
|
369
|
+
"tool_call_id": call_id,
|
|
370
|
+
}
|
|
371
|
+
trace, verdict = pipeline._score_and_gate_preflight(payload)
|
|
372
|
+
if verdict.denied:
|
|
373
|
+
return False
|
|
374
|
+
# Stash for postflight (bounded)
|
|
375
|
+
call_key = f"{sid}:{call_id}"
|
|
376
|
+
if len(_traces) >= _MAX_PENDING:
|
|
377
|
+
# Evict oldest entry
|
|
378
|
+
_traces.pop(next(iter(_traces)), None)
|
|
379
|
+
_traces[call_key] = trace
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
@after_tool_call
|
|
383
|
+
def _traceforge_postflight(ctx):
|
|
384
|
+
sid = getattr(getattr(ctx, "crew", None), "fingerprint", None) or "crewai"
|
|
385
|
+
call_id = getattr(ctx, "tool_call_id", None) or f"{ctx.tool_name}:{id(ctx)}"
|
|
386
|
+
call_key = f"{sid}:{call_id}"
|
|
387
|
+
trace = _traces.pop(call_key, None)
|
|
388
|
+
if trace is None:
|
|
389
|
+
return
|
|
390
|
+
output = getattr(ctx, "output", None)
|
|
391
|
+
pv = pipeline._enforce_postflight(
|
|
392
|
+
trace,
|
|
393
|
+
session_id=sid,
|
|
394
|
+
output={"result": output} if output else None,
|
|
395
|
+
)
|
|
396
|
+
from traceforge.sdk.gate_types import PostflightAction
|
|
397
|
+
|
|
398
|
+
if pv.action == PostflightAction.TERMINATE:
|
|
399
|
+
raise RuntimeError(f"Session terminated by policy: {pv.reason}")
|
|
400
|
+
if pv.action == PostflightAction.SUPPRESS:
|
|
401
|
+
ctx.output = "[output suppressed by policy]"
|
|
402
|
+
elif pv.action == PostflightAction.REDACT and isinstance(output, str):
|
|
403
|
+
ctx.output = pipeline._apply_postflight_to_output(pv, output)
|
|
404
|
+
|
|
405
|
+
def gate_langchain(self, tool):
|
|
406
|
+
"""Wrap a LangChain tool's _run with traceforge gating.
|
|
407
|
+
|
|
408
|
+
Blocking: raises ToolException when preflight returns DENY.
|
|
409
|
+
Session ID: uses tool invocation config's configurable.thread_id or "langchain".
|
|
410
|
+
Idempotent: calling twice on same tool is a no-op.
|
|
411
|
+
"""
|
|
412
|
+
if getattr(tool, "_traceforge_gated", False):
|
|
413
|
+
return tool
|
|
414
|
+
tool._traceforge_gated = True
|
|
415
|
+
|
|
416
|
+
from langchain_core.tools.base import ToolException
|
|
417
|
+
|
|
418
|
+
pipeline = self
|
|
419
|
+
original_run = tool._run
|
|
420
|
+
|
|
421
|
+
def _guarded_run(*args, config=None, run_manager=None, **kwargs):
|
|
422
|
+
import time
|
|
423
|
+
|
|
424
|
+
sid = "langchain"
|
|
425
|
+
if config and isinstance(config, dict):
|
|
426
|
+
configurable = config.get("configurable", {})
|
|
427
|
+
if isinstance(configurable, dict):
|
|
428
|
+
sid = configurable.get("thread_id", sid)
|
|
429
|
+
elif hasattr(config, "configurable"):
|
|
430
|
+
sid = config.configurable.get("thread_id", sid)
|
|
431
|
+
|
|
432
|
+
# Extract tool-relevant input (exclude LangChain internal keys)
|
|
433
|
+
_INTERNAL_KEYS = {"config", "run_manager", "callbacks", "tags", "metadata"}
|
|
434
|
+
if kwargs:
|
|
435
|
+
tool_input = {k: v for k, v in kwargs.items() if k not in _INTERNAL_KEYS}
|
|
436
|
+
elif args:
|
|
437
|
+
tool_input = {"input": args[0]}
|
|
438
|
+
else:
|
|
439
|
+
tool_input = {}
|
|
440
|
+
payload = {
|
|
441
|
+
"tool_name": tool.name,
|
|
442
|
+
"tool_input": tool_input,
|
|
443
|
+
"session_id": sid,
|
|
444
|
+
}
|
|
445
|
+
trace, verdict = pipeline._score_and_gate_preflight(payload)
|
|
446
|
+
if verdict.denied:
|
|
447
|
+
raise ToolException(f"Denied: {verdict.reason}")
|
|
448
|
+
|
|
449
|
+
t0 = time.monotonic()
|
|
450
|
+
error = None
|
|
451
|
+
result = None
|
|
452
|
+
try:
|
|
453
|
+
result = original_run(*args, config=config, run_manager=run_manager, **kwargs)
|
|
454
|
+
except Exception as exc:
|
|
455
|
+
error = str(exc)
|
|
456
|
+
raise
|
|
457
|
+
finally:
|
|
458
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
459
|
+
pv = pipeline._enforce_postflight(
|
|
460
|
+
trace,
|
|
461
|
+
session_id=sid,
|
|
462
|
+
output={"result": result} if error is None else None,
|
|
463
|
+
duration_ms=duration_ms,
|
|
464
|
+
error=error,
|
|
465
|
+
)
|
|
466
|
+
from traceforge.sdk.gate_types import PostflightAction
|
|
467
|
+
|
|
468
|
+
if pv.action == PostflightAction.TERMINATE:
|
|
469
|
+
raise RuntimeError(f"Session terminated by policy: {pv.reason}")
|
|
470
|
+
if pv.action == PostflightAction.SUPPRESS:
|
|
471
|
+
return "[output suppressed by policy]"
|
|
472
|
+
if pv.action == PostflightAction.REDACT and isinstance(result, str):
|
|
473
|
+
return pipeline._apply_postflight_to_output(pv, result)
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
tool._run = _guarded_run
|
|
477
|
+
tool.handle_tool_error = True
|
|
478
|
+
return tool
|
|
479
|
+
|
|
480
|
+
def gate_langgraph(self, tools):
|
|
481
|
+
"""Return a ToolNode with traceforge gating via wrap_tool_call.
|
|
482
|
+
|
|
483
|
+
Blocking: returns denial ToolMessage without calling execute.
|
|
484
|
+
Session ID: from request config's configurable.thread_id or "langgraph".
|
|
485
|
+
"""
|
|
486
|
+
from langgraph.prebuilt import ToolNode
|
|
487
|
+
|
|
488
|
+
pipeline = self
|
|
489
|
+
|
|
490
|
+
def _traceforge_wrapper(request, execute):
|
|
491
|
+
import time
|
|
492
|
+
|
|
493
|
+
from langchain_core.messages import ToolMessage
|
|
494
|
+
|
|
495
|
+
native_id = request.tool_call.get("id")
|
|
496
|
+
# LangGraph passes thread_id via config
|
|
497
|
+
sid = "langgraph"
|
|
498
|
+
if hasattr(request, "config") and hasattr(request.config, "configurable"):
|
|
499
|
+
sid = request.config.configurable.get("thread_id", sid)
|
|
500
|
+
|
|
501
|
+
payload = {
|
|
502
|
+
"tool_name": request.tool_call["name"],
|
|
503
|
+
"tool_input": request.tool_call["args"],
|
|
504
|
+
"session_id": sid,
|
|
505
|
+
"tool_call_id": native_id,
|
|
506
|
+
}
|
|
507
|
+
trace, verdict = pipeline._score_and_gate_preflight(payload)
|
|
508
|
+
if verdict.denied:
|
|
509
|
+
return ToolMessage(
|
|
510
|
+
content=f"Denied: {verdict.reason}",
|
|
511
|
+
tool_call_id=native_id,
|
|
512
|
+
name=request.tool_call["name"],
|
|
513
|
+
status="error",
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
t0 = time.monotonic()
|
|
517
|
+
error = None
|
|
518
|
+
try:
|
|
519
|
+
result = execute(request)
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
error = str(exc)
|
|
522
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
523
|
+
pipeline._enforce_postflight(
|
|
524
|
+
trace,
|
|
525
|
+
session_id=sid,
|
|
526
|
+
duration_ms=duration_ms,
|
|
527
|
+
error=error,
|
|
528
|
+
)
|
|
529
|
+
raise
|
|
530
|
+
|
|
531
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
532
|
+
pv = pipeline._enforce_postflight(
|
|
533
|
+
trace,
|
|
534
|
+
session_id=sid,
|
|
535
|
+
output={"content": getattr(result, "content", str(result))},
|
|
536
|
+
duration_ms=duration_ms,
|
|
537
|
+
)
|
|
538
|
+
from traceforge.sdk.gate_types import PostflightAction
|
|
539
|
+
|
|
540
|
+
if pv.action == PostflightAction.TERMINATE:
|
|
541
|
+
return ToolMessage(
|
|
542
|
+
content=f"Session terminated: {pv.reason}",
|
|
543
|
+
tool_call_id=native_id,
|
|
544
|
+
name=request.tool_call["name"],
|
|
545
|
+
status="error",
|
|
546
|
+
)
|
|
547
|
+
if pv.action == PostflightAction.SUPPRESS:
|
|
548
|
+
return ToolMessage(
|
|
549
|
+
content="[output suppressed by policy]",
|
|
550
|
+
tool_call_id=native_id,
|
|
551
|
+
name=request.tool_call["name"],
|
|
552
|
+
status="success",
|
|
553
|
+
)
|
|
554
|
+
if pv.action == PostflightAction.REDACT:
|
|
555
|
+
content = getattr(result, "content", str(result))
|
|
556
|
+
redacted = pipeline._apply_postflight_to_output(pv, content)
|
|
557
|
+
return ToolMessage(
|
|
558
|
+
content=redacted,
|
|
559
|
+
tool_call_id=native_id,
|
|
560
|
+
name=request.tool_call["name"],
|
|
561
|
+
status="success",
|
|
562
|
+
)
|
|
563
|
+
return result
|
|
564
|
+
|
|
565
|
+
return ToolNode(tools, wrap_tool_call=_traceforge_wrapper)
|
|
566
|
+
|
|
567
|
+
def gate_semantic_kernel(self, kernel) -> None:
|
|
568
|
+
"""Register traceforge as a Semantic Kernel auto function invocation filter.
|
|
569
|
+
|
|
570
|
+
Blocking: skips next_handler and injects denial FunctionResult.
|
|
571
|
+
Session ID: from kernel's service_id or "semantic_kernel".
|
|
572
|
+
"""
|
|
573
|
+
|
|
574
|
+
pipeline = self
|
|
575
|
+
|
|
576
|
+
@kernel.filter(filter_type="auto_function_invocation")
|
|
577
|
+
async def _traceforge_filter(context, next_handler):
|
|
578
|
+
sid = getattr(kernel, "service_id", None) or "semantic_kernel"
|
|
579
|
+
payload = {
|
|
580
|
+
"tool_name": context.function.name,
|
|
581
|
+
"tool_input": dict(context.arguments) if context.arguments else {},
|
|
582
|
+
"session_id": sid,
|
|
583
|
+
}
|
|
584
|
+
trace, verdict = await asyncio.to_thread(pipeline._score_and_gate_preflight, payload)
|
|
585
|
+
if verdict.denied:
|
|
586
|
+
from semantic_kernel.functions import FunctionResult
|
|
587
|
+
|
|
588
|
+
context.function_result = FunctionResult(
|
|
589
|
+
function=context.function.metadata,
|
|
590
|
+
value=f"Tool blocked by policy: {verdict.reason}",
|
|
591
|
+
)
|
|
592
|
+
context.terminate = True
|
|
593
|
+
return
|
|
594
|
+
await next_handler(context)
|
|
595
|
+
result_val = getattr(context.function_result, "value", None)
|
|
596
|
+
pv = pipeline._enforce_postflight(
|
|
597
|
+
trace,
|
|
598
|
+
session_id=sid,
|
|
599
|
+
output={"result": str(result_val)} if result_val else None,
|
|
600
|
+
)
|
|
601
|
+
from traceforge.sdk.gate_types import PostflightAction
|
|
602
|
+
|
|
603
|
+
if pv.action == PostflightAction.TERMINATE:
|
|
604
|
+
context.terminate = True
|
|
605
|
+
elif pv.action == PostflightAction.SUPPRESS:
|
|
606
|
+
from semantic_kernel.functions import FunctionResult
|
|
607
|
+
|
|
608
|
+
context.function_result = FunctionResult(
|
|
609
|
+
function=context.function.metadata,
|
|
610
|
+
value="[output suppressed by policy]",
|
|
611
|
+
)
|
|
612
|
+
elif pv.action == PostflightAction.REDACT and result_val:
|
|
613
|
+
from semantic_kernel.functions import FunctionResult
|
|
614
|
+
|
|
615
|
+
redacted = pipeline._apply_postflight_to_output(pv, str(result_val))
|
|
616
|
+
context.function_result = FunctionResult(
|
|
617
|
+
function=context.function.metadata,
|
|
618
|
+
value=redacted,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
def gate_maf(self):
|
|
622
|
+
"""Return a FunctionMiddleware for Microsoft Agent Framework (MAF).
|
|
623
|
+
|
|
624
|
+
Blocking: raises MiddlewareTermination or skips call_next to deny.
|
|
625
|
+
Session ID: from context.session.conversation_id or "maf".
|
|
626
|
+
"""
|
|
627
|
+
from agent_framework import FunctionMiddleware, MiddlewareTermination
|
|
628
|
+
|
|
629
|
+
pipeline = self
|
|
630
|
+
|
|
631
|
+
class TraceforgeMiddleware(FunctionMiddleware):
|
|
632
|
+
async def process(self, context, call_next):
|
|
633
|
+
import time
|
|
634
|
+
|
|
635
|
+
session = getattr(context, "session", None)
|
|
636
|
+
sid = getattr(session, "conversation_id", None) or "maf"
|
|
637
|
+
payload = {
|
|
638
|
+
"tool_name": context.function.name,
|
|
639
|
+
"tool_input": context.arguments or {},
|
|
640
|
+
"session_id": sid,
|
|
641
|
+
"tool_call_id": getattr(context, "call_id", None),
|
|
642
|
+
}
|
|
643
|
+
trace, verdict = await asyncio.to_thread(
|
|
644
|
+
pipeline._score_and_gate_preflight, payload
|
|
645
|
+
)
|
|
646
|
+
if verdict.denied:
|
|
647
|
+
raise MiddlewareTermination(f"Denied: {verdict.reason}")
|
|
648
|
+
|
|
649
|
+
t0 = time.monotonic()
|
|
650
|
+
error = None
|
|
651
|
+
try:
|
|
652
|
+
await call_next(context)
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
error = str(exc)
|
|
655
|
+
raise
|
|
656
|
+
finally:
|
|
657
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
658
|
+
result = getattr(context, "result", None)
|
|
659
|
+
pv = pipeline._enforce_postflight(
|
|
660
|
+
trace,
|
|
661
|
+
session_id=sid,
|
|
662
|
+
output={"result": str(result)} if result and not error else None,
|
|
663
|
+
duration_ms=duration_ms,
|
|
664
|
+
error=error,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
from traceforge.sdk.gate_types import PostflightAction
|
|
668
|
+
|
|
669
|
+
if pv.action == PostflightAction.TERMINATE:
|
|
670
|
+
raise MiddlewareTermination(f"Session terminated: {pv.reason}")
|
|
671
|
+
if pv.action == PostflightAction.SUPPRESS:
|
|
672
|
+
context.result = "[output suppressed by policy]"
|
|
673
|
+
elif pv.action == PostflightAction.REDACT:
|
|
674
|
+
result = getattr(context, "result", None)
|
|
675
|
+
if isinstance(result, str):
|
|
676
|
+
context.result = pipeline._apply_postflight_to_output(pv, result)
|
|
677
|
+
|
|
678
|
+
return TraceforgeMiddleware()
|
|
679
|
+
|
|
680
|
+
def gate_smolagents(self, agent_cls=None):
|
|
681
|
+
"""Return a TraceforgeAgent subclass that gates tool calls for smolagents.
|
|
682
|
+
|
|
683
|
+
Blocking: returns denial string as observation without executing the tool.
|
|
684
|
+
Session ID: from agent.session_id or "smolagents".
|
|
685
|
+
"""
|
|
686
|
+
if agent_cls is None:
|
|
687
|
+
from smolagents import ToolCallingAgent
|
|
688
|
+
|
|
689
|
+
agent_cls = ToolCallingAgent
|
|
690
|
+
|
|
691
|
+
pipeline = self
|
|
692
|
+
|
|
693
|
+
class _TraceforgeAgent(agent_cls):
|
|
694
|
+
def execute_tool_call(self, tool_name: str, arguments) -> any:
|
|
695
|
+
import time
|
|
696
|
+
|
|
697
|
+
sid = getattr(self, "session_id", None) or "smolagents"
|
|
698
|
+
payload = {
|
|
699
|
+
"tool_name": tool_name,
|
|
700
|
+
"tool_input": arguments if isinstance(arguments, dict) else {"raw": arguments},
|
|
701
|
+
"session_id": sid,
|
|
702
|
+
}
|
|
703
|
+
trace, verdict = pipeline._score_and_gate_preflight(payload)
|
|
704
|
+
if verdict.denied:
|
|
705
|
+
return f"[BLOCKED] {verdict.reason}"
|
|
706
|
+
|
|
707
|
+
t0 = time.monotonic()
|
|
708
|
+
error = None
|
|
709
|
+
result = None
|
|
710
|
+
try:
|
|
711
|
+
result = super().execute_tool_call(tool_name, arguments)
|
|
712
|
+
except Exception as exc:
|
|
713
|
+
error = str(exc)
|
|
714
|
+
raise
|
|
715
|
+
finally:
|
|
716
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
717
|
+
pv = pipeline._enforce_postflight(
|
|
718
|
+
trace,
|
|
719
|
+
session_id=sid,
|
|
720
|
+
output={"result": str(result)} if error is None else None,
|
|
721
|
+
duration_ms=duration_ms,
|
|
722
|
+
error=error,
|
|
723
|
+
)
|
|
724
|
+
from traceforge.sdk.gate_types import PostflightAction
|
|
725
|
+
|
|
726
|
+
if pv.action == PostflightAction.TERMINATE:
|
|
727
|
+
raise RuntimeError(f"Session terminated by policy: {pv.reason}")
|
|
728
|
+
if pv.action == PostflightAction.SUPPRESS:
|
|
729
|
+
return "[output suppressed by policy]"
|
|
730
|
+
if pv.action == PostflightAction.REDACT and isinstance(result, str):
|
|
731
|
+
return pipeline._apply_postflight_to_output(pv, result)
|
|
732
|
+
return result
|
|
733
|
+
|
|
734
|
+
return _TraceforgeAgent
|
|
735
|
+
|
|
736
|
+
def gate_pydantic_ai(self, agent) -> None:
|
|
737
|
+
"""Gate a Pydantic AI agent's tool calls via a wrapping toolset.
|
|
738
|
+
|
|
739
|
+
Blocking: preflight DENY raises ``RuntimeError('Denied: ...')``, which
|
|
740
|
+
propagates out of the agent run so the tool body never executes.
|
|
741
|
+
Session ID: from ``ctx.run_id`` (Pydantic AI's native per-run UUID).
|
|
742
|
+
Postflight: SUPPRESS/REDACT rewrite the tool's return value; TERMINATE raises.
|
|
743
|
+
Idempotent: calling twice on the same agent is a no-op.
|
|
744
|
+
|
|
745
|
+
Pydantic AI (>=1) exposes no tool-execution hooks; the supported per-tool
|
|
746
|
+
interception point is ``AbstractToolset.call_tool``. We wrap each of the
|
|
747
|
+
agent's leaf toolsets (its function tools plus any user/dynamic toolsets) in a
|
|
748
|
+
``WrapperToolset`` subclass, so every tool call routes through the gate. Because
|
|
749
|
+
preflight and postflight run in the same ``call_tool`` invocation, the trace is
|
|
750
|
+
a local variable — no cross-hook stash is needed. Apply after tools are
|
|
751
|
+
registered.
|
|
752
|
+
"""
|
|
753
|
+
if getattr(agent, "_traceforge_gated", False):
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
from pydantic_ai.toolsets import WrapperToolset
|
|
757
|
+
|
|
758
|
+
pipeline = self
|
|
759
|
+
|
|
760
|
+
class _TraceforgeGateToolset(WrapperToolset):
|
|
761
|
+
async def call_tool(self, name, tool_args, ctx, tool):
|
|
762
|
+
from traceforge.sdk.gate_types import PostflightAction
|
|
763
|
+
|
|
764
|
+
sid = str(getattr(ctx, "run_id", None) or "pydantic_ai")
|
|
765
|
+
payload = {
|
|
766
|
+
"tool_name": name,
|
|
767
|
+
"tool_input": tool_args if isinstance(tool_args, dict) else {"raw": tool_args},
|
|
768
|
+
"session_id": sid,
|
|
769
|
+
"tool_call_id": getattr(ctx, "tool_call_id", None),
|
|
770
|
+
}
|
|
771
|
+
trace, verdict = await asyncio.to_thread(
|
|
772
|
+
pipeline._score_and_gate_preflight, payload
|
|
773
|
+
)
|
|
774
|
+
if verdict.denied:
|
|
775
|
+
raise RuntimeError(f"Denied: {verdict.reason}")
|
|
776
|
+
|
|
777
|
+
result = await super().call_tool(name, tool_args, ctx, tool)
|
|
778
|
+
|
|
779
|
+
pv = pipeline._enforce_postflight(
|
|
780
|
+
trace,
|
|
781
|
+
session_id=sid,
|
|
782
|
+
output={"result": str(result)} if result is not None else None,
|
|
783
|
+
)
|
|
784
|
+
if pv.action == PostflightAction.TERMINATE:
|
|
785
|
+
raise RuntimeError(f"Session terminated by policy: {pv.reason}")
|
|
786
|
+
if pv.action == PostflightAction.SUPPRESS:
|
|
787
|
+
return "[output suppressed by policy]"
|
|
788
|
+
if pv.action == PostflightAction.REDACT and isinstance(result, str):
|
|
789
|
+
return pipeline._apply_postflight_to_output(pv, result)
|
|
790
|
+
return result
|
|
791
|
+
|
|
792
|
+
# Wrap every leaf toolset the agent will assemble into its run toolset, then
|
|
793
|
+
# mark gated only after wrapping succeeds (no half-gated state on failure).
|
|
794
|
+
agent._function_toolset = _TraceforgeGateToolset(agent._function_toolset)
|
|
795
|
+
agent._user_toolsets = [_TraceforgeGateToolset(ts) for ts in agent._user_toolsets]
|
|
796
|
+
agent._dynamic_toolsets = [_TraceforgeGateToolset(ts) for ts in agent._dynamic_toolsets]
|
|
797
|
+
agent._traceforge_gated = True
|
|
798
|
+
|
|
799
|
+
def gate_openai_agents(self, agent):
|
|
800
|
+
"""Register traceforge as an OpenAI Agents SDK input guardrail.
|
|
801
|
+
|
|
802
|
+
Blocking: raises GuardrailTripwireTriggered which rejects the entire turn.
|
|
803
|
+
Session ID: from agent.name or "openai_agents".
|
|
804
|
+
Idempotent: calling twice on same agent is a no-op.
|
|
805
|
+
|
|
806
|
+
NOTE: Input guardrails fire on the agent's input message, NOT on individual
|
|
807
|
+
tool calls. For per-tool-call gating, use needs_approval=True on tools and
|
|
808
|
+
integrate via the approval handler pattern (see gating spec §5b).
|
|
809
|
+
"""
|
|
810
|
+
if getattr(agent, "_traceforge_gated", False):
|
|
811
|
+
return agent
|
|
812
|
+
agent._traceforge_gated = True
|
|
813
|
+
|
|
814
|
+
pipeline = self
|
|
815
|
+
|
|
816
|
+
from agents import input_guardrail, GuardrailFunctionOutput
|
|
817
|
+
|
|
818
|
+
@input_guardrail
|
|
819
|
+
async def traceforge_guardrail(ctx, agent_instance, input_data):
|
|
820
|
+
sid = getattr(agent_instance, "name", None) or "openai_agents"
|
|
821
|
+
payload = {
|
|
822
|
+
"tool_name": getattr(input_data, "tool_name", "unknown"),
|
|
823
|
+
"tool_input": getattr(input_data, "tool_input", {}),
|
|
824
|
+
"session_id": sid,
|
|
825
|
+
}
|
|
826
|
+
trace, verdict = await asyncio.to_thread(pipeline._score_and_gate_preflight, payload)
|
|
827
|
+
if verdict.denied:
|
|
828
|
+
return GuardrailFunctionOutput(
|
|
829
|
+
output_info=verdict.reason,
|
|
830
|
+
tripwire_triggered=True,
|
|
831
|
+
)
|
|
832
|
+
return GuardrailFunctionOutput(
|
|
833
|
+
output_info="allowed",
|
|
834
|
+
tripwire_triggered=False,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
if not hasattr(agent, "input_guardrails"):
|
|
838
|
+
agent.input_guardrails = []
|
|
839
|
+
agent.input_guardrails.append(traceforge_guardrail)
|
|
840
|
+
return agent
|