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,398 @@
|
|
|
1
|
+
"""Title providers: offline floor + opt-in LiteLLM API tier (session and span).
|
|
2
|
+
|
|
3
|
+
This module owns the LiteLLM plumbing, the opt-in key-presence gate, and the
|
|
4
|
+
graceful-fallback contract shared by both configurable title surfaces:
|
|
5
|
+
|
|
6
|
+
* **Session naming.** :func:`build_session_titler_split` reads
|
|
7
|
+
:class:`~traceforge.config.SessionNamingConfig` and returns a
|
|
8
|
+
:class:`SessionTitler` (an immediate :class:`HeuristicProvider` floor + an
|
|
9
|
+
optional :class:`ApiProvider` refiner). The heuristic is a free, offline
|
|
10
|
+
extractive title over the user's own words; the API tier engages only when a
|
|
11
|
+
key is present.
|
|
12
|
+
* **Activity/step titling.** :func:`build_activity_refiner` reads
|
|
13
|
+
:class:`~traceforge.config.ActivityTitlingConfig` and returns an optional
|
|
14
|
+
:class:`ActivityApiProvider` refiner. Here the offline floor is the *packaged
|
|
15
|
+
ONNX span model* (owned by :mod:`traceforge.title.inferencer`, emitted the
|
|
16
|
+
instant an activity closes); the refiner, when configured and keyed, upgrades
|
|
17
|
+
the activity title and all its step titles in one call, off the hot path.
|
|
18
|
+
|
|
19
|
+
In both cases the API tier is **strictly opt-in and never load-bearing**: absent
|
|
20
|
+
a key -- or on any API call that fails, times out, or returns nothing usable --
|
|
21
|
+
the offline floor stands, so a missing key or a flaky network never errors,
|
|
22
|
+
blocks, or empties a title.
|
|
23
|
+
|
|
24
|
+
The API key is never read from config: LiteLLM sources it from the provider's
|
|
25
|
+
conventional env var; ``api.api_key_env`` only overrides *which* var to read.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import TYPE_CHECKING, Callable
|
|
35
|
+
|
|
36
|
+
from .hygiene import clean_title
|
|
37
|
+
from .heuristics import heuristic_title
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from traceforge.config.models import (
|
|
41
|
+
ActivityTitlingApiConfig,
|
|
42
|
+
ActivityTitlingConfig,
|
|
43
|
+
SessionNamingApiConfig,
|
|
44
|
+
SessionNamingConfig,
|
|
45
|
+
SessionNamingHeuristicConfig,
|
|
46
|
+
TitleApiConfig,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
#: Kept terse and instruction-tight; the model must return only the title.
|
|
52
|
+
_SYSTEM = (
|
|
53
|
+
"You name a developer's coding session from their first message. "
|
|
54
|
+
"Reply with ONLY a short, specific title in imperative mood, at most {max_words} "
|
|
55
|
+
"words, no trailing punctuation, no quotes. Name the concrete task, not the tone."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HeuristicProvider:
|
|
60
|
+
"""The offline extractive floor. Always available; needs no key or network."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, cfg: "SessionNamingHeuristicConfig") -> None:
|
|
63
|
+
self._method = cfg.method
|
|
64
|
+
self._max_words = cfg.max_words
|
|
65
|
+
self._max_chars = cfg.max_chars
|
|
66
|
+
|
|
67
|
+
def title(self, text: str) -> str:
|
|
68
|
+
return heuristic_title(text, self._method, self._max_words, self._max_chars)
|
|
69
|
+
|
|
70
|
+
__call__ = title
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ApiProvider:
|
|
74
|
+
"""Abstractive session titles via LiteLLM (any provider + local runtimes).
|
|
75
|
+
|
|
76
|
+
Returns ``""`` on any failure so the caller can fall back to the heuristic.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, cfg: "SessionNamingApiConfig", max_words: int = 8) -> None:
|
|
80
|
+
self._cfg = cfg
|
|
81
|
+
self._max_words = max_words
|
|
82
|
+
|
|
83
|
+
def title(self, text: str) -> str:
|
|
84
|
+
try:
|
|
85
|
+
import litellm
|
|
86
|
+
except ImportError: # pragma: no cover - litellm ships as a base dep
|
|
87
|
+
logger.debug("litellm unavailable; session-naming API tier disabled")
|
|
88
|
+
return ""
|
|
89
|
+
cfg = self._cfg
|
|
90
|
+
system = _SYSTEM.format(max_words=self._max_words)
|
|
91
|
+
kwargs: dict = {
|
|
92
|
+
"model": cfg.model,
|
|
93
|
+
"messages": [
|
|
94
|
+
{"role": "system", "content": system},
|
|
95
|
+
{"role": "user", "content": text},
|
|
96
|
+
],
|
|
97
|
+
"max_tokens": cfg.max_tokens,
|
|
98
|
+
"timeout": cfg.timeout,
|
|
99
|
+
"temperature": 0.0,
|
|
100
|
+
}
|
|
101
|
+
if cfg.api_base:
|
|
102
|
+
kwargs["api_base"] = cfg.api_base
|
|
103
|
+
if cfg.api_key_env:
|
|
104
|
+
key = os.environ.get(cfg.api_key_env)
|
|
105
|
+
if not key:
|
|
106
|
+
return ""
|
|
107
|
+
kwargs["api_key"] = key
|
|
108
|
+
try:
|
|
109
|
+
resp = litellm.completion(**kwargs)
|
|
110
|
+
raw = (resp.choices[0].message.content or "").strip()
|
|
111
|
+
except Exception as exc: # noqa: BLE001 - any provider/network error -> fallback
|
|
112
|
+
logger.warning("session-naming API call failed (%s); using heuristic", exc)
|
|
113
|
+
return ""
|
|
114
|
+
return clean_title(raw)
|
|
115
|
+
|
|
116
|
+
__call__ = title
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _WithFallback:
|
|
120
|
+
"""Try the API tier; fall back to the heuristic on empty/failed output."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, primary: ApiProvider, fallback: HeuristicProvider) -> None:
|
|
123
|
+
self._primary = primary
|
|
124
|
+
self._fallback = fallback
|
|
125
|
+
|
|
126
|
+
def title(self, text: str) -> str:
|
|
127
|
+
out = self._primary.title(text)
|
|
128
|
+
return out if out and out.strip() else self._fallback.title(text)
|
|
129
|
+
|
|
130
|
+
__call__ = title
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _api_key_present(cfg: "TitleApiConfig") -> bool:
|
|
134
|
+
"""True iff the API tier can authenticate right now (opt-in gate).
|
|
135
|
+
|
|
136
|
+
Shared by both title surfaces (session naming and activity/step titling),
|
|
137
|
+
since both ``api`` sub-configs derive from :class:`TitleApiConfig`. Honors an
|
|
138
|
+
explicit ``api_key_env`` override; otherwise defers to LiteLLM's
|
|
139
|
+
provider-aware environment validation so any provider's conventional key var
|
|
140
|
+
counts. Absent LiteLLM or on any error, reports not-present (-> offline floor).
|
|
141
|
+
"""
|
|
142
|
+
if cfg.api_base and not cfg.api_key_env:
|
|
143
|
+
# Local/self-hosted runtimes (ollama/vllm) often need no key.
|
|
144
|
+
return True
|
|
145
|
+
if cfg.api_key_env:
|
|
146
|
+
return bool(os.environ.get(cfg.api_key_env))
|
|
147
|
+
try:
|
|
148
|
+
from litellm import validate_environment
|
|
149
|
+
|
|
150
|
+
info = validate_environment(cfg.model)
|
|
151
|
+
return bool(info.get("keys_in_environment"))
|
|
152
|
+
except Exception: # noqa: BLE001 - litellm missing or signature drift -> off
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_session_titler(cfg: "SessionNamingConfig | None" = None) -> Callable[[str], str]:
|
|
157
|
+
"""Build the configured session titler (defaults resolved from global config).
|
|
158
|
+
|
|
159
|
+
The returned callable maps the first substantive user message to a title.
|
|
160
|
+
Falls back to the heuristic whenever the API tier is unconfigured/unusable.
|
|
161
|
+
|
|
162
|
+
This is the *blocking* single-callable form (API tier tried inline, then the
|
|
163
|
+
heuristic). The live pipeline instead uses :func:`build_session_titler_split`
|
|
164
|
+
so it can emit the heuristic immediately and refine via the API off the hot
|
|
165
|
+
path; this form is retained for callers that want one synchronous title.
|
|
166
|
+
"""
|
|
167
|
+
split = build_session_titler_split(cfg)
|
|
168
|
+
if split.api_refiner is not None:
|
|
169
|
+
return _WithFallback(split.api_refiner, split.heuristic)
|
|
170
|
+
return split.heuristic
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class SessionTitler:
|
|
175
|
+
"""A session titler split into an immediate floor and an optional refiner.
|
|
176
|
+
|
|
177
|
+
* ``heuristic`` -- the free, offline, non-blocking title. Always present; it
|
|
178
|
+
is emitted the instant the opening request arrives so the event stream
|
|
179
|
+
never waits on a title.
|
|
180
|
+
* ``api_refiner`` -- present only when ``strategy=api`` *and* a usable API key
|
|
181
|
+
is configured. It returns an abstractive upgrade of the title (or ``""`` on
|
|
182
|
+
any failure/timeout) and is meant to run **off the hot path**: the pipeline
|
|
183
|
+
runs it in a worker thread and emits its result as a later session
|
|
184
|
+
:class:`~traceforge.types.TitleUpdate`. ``None`` when no usable API tier is
|
|
185
|
+
configured, in which case the heuristic is the final title.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
heuristic: Callable[[str], str]
|
|
189
|
+
api_refiner: Callable[[str], str] | None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_session_titler_split(
|
|
193
|
+
cfg: "SessionNamingConfig | None" = None,
|
|
194
|
+
) -> SessionTitler:
|
|
195
|
+
"""Build the session titler as a non-blocking heuristic + optional refiner.
|
|
196
|
+
|
|
197
|
+
Unlike :func:`build_session_titler`, the API tier (when configured and keyed)
|
|
198
|
+
is returned *separately* as ``api_refiner`` rather than wrapped inline, so the
|
|
199
|
+
pipeline can emit the heuristic title immediately and only later replace it
|
|
200
|
+
with the API result — never blocking live event emission on the network.
|
|
201
|
+
"""
|
|
202
|
+
if cfg is None:
|
|
203
|
+
from traceforge.config import get_config
|
|
204
|
+
|
|
205
|
+
cfg = get_config().title.session_naming
|
|
206
|
+
|
|
207
|
+
heuristic = HeuristicProvider(cfg.heuristic)
|
|
208
|
+
if cfg.strategy == "api" and _api_key_present(cfg.api):
|
|
209
|
+
return SessionTitler(heuristic, ApiProvider(cfg.api, cfg.heuristic.max_words))
|
|
210
|
+
if cfg.strategy == "api":
|
|
211
|
+
logger.info(
|
|
212
|
+
"title.session_naming.strategy=api but no API key for model %r found in "
|
|
213
|
+
"the environment; using the offline heuristic.",
|
|
214
|
+
cfg.api.model,
|
|
215
|
+
)
|
|
216
|
+
return SessionTitler(heuristic, None)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ─── Activity/step (span) title refinement ───────────────────────────────────
|
|
220
|
+
|
|
221
|
+
#: Terse, instruction-tight span prompt. The model sees each segment's distilled
|
|
222
|
+
#: context and must return ONLY a JSON object titling the activity and its steps.
|
|
223
|
+
_SPAN_SYSTEM = (
|
|
224
|
+
"You title one activity in a developer's coding session and each of its "
|
|
225
|
+
"numbered steps, from their distilled contexts. Reply with ONLY a JSON "
|
|
226
|
+
'object of the form {{"activity": "<title>", "steps": ["<step 1>", '
|
|
227
|
+
'"<step 2>", ...]}} -- exactly one step title per numbered step, in order. '
|
|
228
|
+
"Each title is a short, specific phrase in imperative or gerund mood, at most "
|
|
229
|
+
"{max_words} words, no trailing punctuation, no quotes. Keep every step title "
|
|
230
|
+
"distinct from the activity title and from the other step titles."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class ActivitySpan:
|
|
236
|
+
"""The distilled context of a closed activity and its steps, fed to the API.
|
|
237
|
+
|
|
238
|
+
``activity_context`` is the distilled context over the whole activity;
|
|
239
|
+
``step_contexts`` holds one distilled context per step, in order. Both are
|
|
240
|
+
produced by :func:`traceforge.title.context.distilled_context`, so the API
|
|
241
|
+
tier sees the same source-agnostic slots the packaged model was trained on.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
activity_context: str
|
|
245
|
+
step_contexts: list[str]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class ActivityTitles:
|
|
250
|
+
"""The API's proposed titles for an :class:`ActivitySpan`.
|
|
251
|
+
|
|
252
|
+
``activity`` is the refined activity title (``None`` when the API declined or
|
|
253
|
+
failed); ``steps`` is aligned index-for-index with the span's ``step_contexts``
|
|
254
|
+
-- ``None`` in any slot the API did not usefully cover, so the caller keeps
|
|
255
|
+
that segment's packaged-model title.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
activity: str | None
|
|
259
|
+
steps: list[str | None]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _empty_titles(n_steps: int) -> ActivityTitles:
|
|
263
|
+
return ActivityTitles(None, [None] * n_steps)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _loads_json_object(raw: str):
|
|
267
|
+
"""Best-effort parse of a JSON object from a model reply.
|
|
268
|
+
|
|
269
|
+
Tolerates a model that wraps the object in prose or code fences by falling
|
|
270
|
+
back to the outermost ``{...}`` slice. Returns the parsed object or ``None``.
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
return json.loads(raw)
|
|
274
|
+
except (ValueError, TypeError):
|
|
275
|
+
pass
|
|
276
|
+
start = raw.find("{")
|
|
277
|
+
end = raw.rfind("}")
|
|
278
|
+
if start != -1 and end > start:
|
|
279
|
+
try:
|
|
280
|
+
return json.loads(raw[start : end + 1])
|
|
281
|
+
except (ValueError, TypeError):
|
|
282
|
+
return None
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class ActivityApiProvider:
|
|
287
|
+
"""Abstractive activity/step titles via LiteLLM (any provider + local runtimes).
|
|
288
|
+
|
|
289
|
+
One :meth:`refine` call per closed activity returns the activity title and all
|
|
290
|
+
of its step titles together, so the model sees the full activity context and
|
|
291
|
+
can keep step titles distinct. Returns :class:`ActivityTitles` with ``None``
|
|
292
|
+
slots on any failure (missing key, provider/network error, timeout, or an
|
|
293
|
+
unparseable reply) so the caller falls back to the packaged-model titles.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
def __init__(self, cfg: "ActivityTitlingApiConfig", max_words: int = 8) -> None:
|
|
297
|
+
self._cfg = cfg
|
|
298
|
+
self._max_words = max_words
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _render(span: ActivitySpan) -> str:
|
|
302
|
+
lines = [f"activity: {span.activity_context}"]
|
|
303
|
+
for i, ctx in enumerate(span.step_contexts, start=1):
|
|
304
|
+
lines.append(f"step {i}: {ctx}")
|
|
305
|
+
return "\n".join(lines)
|
|
306
|
+
|
|
307
|
+
def refine(self, span: ActivitySpan) -> ActivityTitles:
|
|
308
|
+
n = len(span.step_contexts)
|
|
309
|
+
try:
|
|
310
|
+
import litellm
|
|
311
|
+
except ImportError: # pragma: no cover - litellm ships as a base dep
|
|
312
|
+
logger.debug("litellm unavailable; activity-titling API tier disabled")
|
|
313
|
+
return _empty_titles(n)
|
|
314
|
+
cfg = self._cfg
|
|
315
|
+
system = _SPAN_SYSTEM.format(max_words=self._max_words)
|
|
316
|
+
kwargs: dict = {
|
|
317
|
+
"model": cfg.model,
|
|
318
|
+
"messages": [
|
|
319
|
+
{"role": "system", "content": system},
|
|
320
|
+
{"role": "user", "content": self._render(span)},
|
|
321
|
+
],
|
|
322
|
+
"max_tokens": cfg.max_tokens,
|
|
323
|
+
"timeout": cfg.timeout,
|
|
324
|
+
"temperature": 0.0,
|
|
325
|
+
}
|
|
326
|
+
if cfg.api_base:
|
|
327
|
+
kwargs["api_base"] = cfg.api_base
|
|
328
|
+
if cfg.api_key_env:
|
|
329
|
+
key = os.environ.get(cfg.api_key_env)
|
|
330
|
+
if not key:
|
|
331
|
+
return _empty_titles(n)
|
|
332
|
+
kwargs["api_key"] = key
|
|
333
|
+
try:
|
|
334
|
+
resp = litellm.completion(**kwargs)
|
|
335
|
+
raw = (resp.choices[0].message.content or "").strip()
|
|
336
|
+
except Exception as exc: # noqa: BLE001 - any provider/network error -> fallback
|
|
337
|
+
logger.warning("activity-titling API call failed (%s); using packaged model", exc)
|
|
338
|
+
return _empty_titles(n)
|
|
339
|
+
return self._parse(raw, n)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _parse(raw: str, n_steps: int) -> ActivityTitles:
|
|
343
|
+
data = _loads_json_object(raw)
|
|
344
|
+
if not isinstance(data, dict):
|
|
345
|
+
return _empty_titles(n_steps)
|
|
346
|
+
act = data.get("activity")
|
|
347
|
+
activity = clean_title(act) if isinstance(act, str) and act.strip() else None
|
|
348
|
+
steps: list[str | None] = [None] * n_steps
|
|
349
|
+
raw_steps = data.get("steps")
|
|
350
|
+
if isinstance(raw_steps, list):
|
|
351
|
+
for i in range(min(n_steps, len(raw_steps))):
|
|
352
|
+
s = raw_steps[i]
|
|
353
|
+
if isinstance(s, str) and s.strip():
|
|
354
|
+
cleaned = clean_title(s)
|
|
355
|
+
steps[i] = cleaned or None
|
|
356
|
+
return ActivityTitles(activity or None, steps)
|
|
357
|
+
|
|
358
|
+
__call__ = refine
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def build_activity_refiner(
|
|
362
|
+
cfg: "ActivityTitlingConfig | None" = None,
|
|
363
|
+
) -> "ActivityApiProvider | None":
|
|
364
|
+
"""Build the optional off-hot-path activity/step title refiner.
|
|
365
|
+
|
|
366
|
+
Returns an :class:`ActivityApiProvider` only when ``strategy=api`` *and* a
|
|
367
|
+
usable API key is present in the environment; otherwise ``None``, in which
|
|
368
|
+
case the packaged ONNX titles (emitted the instant an activity closes) are
|
|
369
|
+
final. Mirrors :func:`build_session_titler_split`'s ``api_refiner`` gate --
|
|
370
|
+
the packaged model itself is the always-present floor, owned by the inferencer.
|
|
371
|
+
"""
|
|
372
|
+
if cfg is None:
|
|
373
|
+
from traceforge.config import get_config
|
|
374
|
+
|
|
375
|
+
cfg = get_config().title.activity_titling
|
|
376
|
+
|
|
377
|
+
if cfg.strategy == "api" and _api_key_present(cfg.api):
|
|
378
|
+
return ActivityApiProvider(cfg.api)
|
|
379
|
+
if cfg.strategy == "api":
|
|
380
|
+
logger.info(
|
|
381
|
+
"title.activity_titling.strategy=api but no API key for model %r found in "
|
|
382
|
+
"the environment; using the packaged ONNX titler.",
|
|
383
|
+
cfg.api.model,
|
|
384
|
+
)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
__all__ = [
|
|
389
|
+
"HeuristicProvider",
|
|
390
|
+
"ApiProvider",
|
|
391
|
+
"SessionTitler",
|
|
392
|
+
"ActivitySpan",
|
|
393
|
+
"ActivityTitles",
|
|
394
|
+
"ActivityApiProvider",
|
|
395
|
+
"build_session_titler",
|
|
396
|
+
"build_session_titler_split",
|
|
397
|
+
"build_activity_refiner",
|
|
398
|
+
]
|
traceforge/trace.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""The unified pipeline type: EventTrace.
|
|
2
|
+
|
|
3
|
+
An EventTrace is the single object that flows through the entire traceforge pipeline.
|
|
4
|
+
It enters sparse (identity fields only from the adapter), accumulates
|
|
5
|
+
classification fields from the enricher, and assessment fields from the scorer.
|
|
6
|
+
By the time it reaches the gate callback, it is fully populated.
|
|
7
|
+
|
|
8
|
+
All dimension types (EventKind, Mechanism, Effect, etc.) are StrEnums generated
|
|
9
|
+
by datamodel-code-generator from classify/schema.yaml — see _generated.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from traceforge._generated import (
|
|
21
|
+
Action,
|
|
22
|
+
Capability,
|
|
23
|
+
Effect,
|
|
24
|
+
EventKind,
|
|
25
|
+
Mechanism,
|
|
26
|
+
Recommendation,
|
|
27
|
+
RiskBand,
|
|
28
|
+
Role,
|
|
29
|
+
Scope,
|
|
30
|
+
Structure,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
SCHEMA_VERSION = "2"
|
|
34
|
+
|
|
35
|
+
# Sentinel for "field not provided" in mutation helpers
|
|
36
|
+
_UNSET: Any = object()
|
|
37
|
+
|
|
38
|
+
# Empty frozen map constant
|
|
39
|
+
EMPTY_MAP: MappingProxyType = MappingProxyType({})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TraceStage(StrEnum):
|
|
43
|
+
"""Lifecycle stage of an EventTrace in the pipeline."""
|
|
44
|
+
|
|
45
|
+
ADAPTED = "adapted"
|
|
46
|
+
CLASSIFIED = "classified"
|
|
47
|
+
ASSESSED = "assessed"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _deep_freeze(obj: Any) -> Any:
|
|
51
|
+
"""Recursively freeze a nested structure into immutable types."""
|
|
52
|
+
if isinstance(obj, MappingProxyType):
|
|
53
|
+
return obj
|
|
54
|
+
if isinstance(obj, dict):
|
|
55
|
+
return MappingProxyType({k: _deep_freeze(v) for k, v in obj.items()})
|
|
56
|
+
if isinstance(obj, (list, tuple)):
|
|
57
|
+
return tuple(_deep_freeze(v) for v in obj)
|
|
58
|
+
if isinstance(obj, set):
|
|
59
|
+
return frozenset(_deep_freeze(v) for v in obj)
|
|
60
|
+
return obj
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class EventTrace:
|
|
65
|
+
"""The atomic unit of traceforge. One per observed event.
|
|
66
|
+
|
|
67
|
+
Lifecycle:
|
|
68
|
+
1. Adapter creates with identity fields + raw_event
|
|
69
|
+
2. Enricher fills classification fields (mechanism, effect, etc.)
|
|
70
|
+
3. Scorer fills assessment fields (risk_score, suggested_action, etc.)
|
|
71
|
+
4. Gate callback receives the fully-enriched EventTrace
|
|
72
|
+
5. Sinks persist the final EventTrace
|
|
73
|
+
|
|
74
|
+
All enum fields accept raw strings and coerce to StrEnum members in
|
|
75
|
+
__post_init__. Invalid values raise ValueError immediately.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# ─── Identity (adapter fills) ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
id: str
|
|
81
|
+
kind: EventKind
|
|
82
|
+
session_id: str
|
|
83
|
+
tool_call_id: str
|
|
84
|
+
timestamp: datetime
|
|
85
|
+
source_key: str
|
|
86
|
+
raw_event: dict[str, Any] | MappingProxyType = field(repr=False, compare=False)
|
|
87
|
+
parent_tool_call_id: str | None = None
|
|
88
|
+
|
|
89
|
+
# ─── Tool identity (adapter fills for tool.call.* events) ─────────────────
|
|
90
|
+
# gen_ai.tool.name → tool_name
|
|
91
|
+
# gen_ai.tool.call.arguments → tool_input
|
|
92
|
+
# gen_ai.tool.call.result → tool_result
|
|
93
|
+
|
|
94
|
+
tool_name: str | None = None
|
|
95
|
+
tool_input: MappingProxyType = field(default_factory=lambda: EMPTY_MAP)
|
|
96
|
+
tool_result: str | None = None
|
|
97
|
+
target_resource: str | None = None
|
|
98
|
+
|
|
99
|
+
# ─── Classification (enricher fills) ──────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
mechanism: Mechanism | None = None
|
|
102
|
+
effect: Effect | None = None
|
|
103
|
+
scope: tuple[Scope, ...] = ()
|
|
104
|
+
role: tuple[Role, ...] = ()
|
|
105
|
+
action: tuple[Action, ...] = ()
|
|
106
|
+
capability: tuple[Capability, ...] = ()
|
|
107
|
+
structure: tuple[Structure, ...] = ()
|
|
108
|
+
canonical_tool: str | None = None
|
|
109
|
+
|
|
110
|
+
# ─── Assessment (scorer fills) ────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
risk_score: int | None = None
|
|
113
|
+
risk_band: RiskBand | None = None
|
|
114
|
+
suggested_action: Recommendation | None = None
|
|
115
|
+
reason: str | None = None
|
|
116
|
+
|
|
117
|
+
# ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
stage: TraceStage = TraceStage.ADAPTED
|
|
120
|
+
|
|
121
|
+
# ─── Extensions ───────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
attributes: MappingProxyType = field(default_factory=lambda: EMPTY_MAP)
|
|
124
|
+
schema_version: str = SCHEMA_VERSION
|
|
125
|
+
|
|
126
|
+
# ─── Post-init: deep-freeze + coerce strings to StrEnums ──────────────────
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
# Deep-freeze all mapping fields
|
|
130
|
+
raw = self.raw_event
|
|
131
|
+
if isinstance(raw, dict) and not isinstance(raw, MappingProxyType):
|
|
132
|
+
object.__setattr__(self, "raw_event", _deep_freeze(raw))
|
|
133
|
+
elif isinstance(raw, MappingProxyType):
|
|
134
|
+
pass # already frozen
|
|
135
|
+
|
|
136
|
+
ti = self.tool_input
|
|
137
|
+
if isinstance(ti, dict) and not isinstance(ti, MappingProxyType):
|
|
138
|
+
object.__setattr__(self, "tool_input", _deep_freeze(ti))
|
|
139
|
+
|
|
140
|
+
attrs = self.attributes
|
|
141
|
+
if isinstance(attrs, dict) and not isinstance(attrs, MappingProxyType):
|
|
142
|
+
object.__setattr__(self, "attributes", _deep_freeze(attrs))
|
|
143
|
+
|
|
144
|
+
# Coerce scalar enums (StrEnum constructor validates + raises ValueError)
|
|
145
|
+
object.__setattr__(self, "kind", EventKind(self.kind))
|
|
146
|
+
object.__setattr__(self, "stage", TraceStage(self.stage))
|
|
147
|
+
if self.mechanism is not None:
|
|
148
|
+
object.__setattr__(self, "mechanism", Mechanism(self.mechanism))
|
|
149
|
+
if self.effect is not None:
|
|
150
|
+
object.__setattr__(self, "effect", Effect(self.effect))
|
|
151
|
+
if self.risk_band is not None:
|
|
152
|
+
object.__setattr__(self, "risk_band", RiskBand(self.risk_band))
|
|
153
|
+
if self.suggested_action is not None:
|
|
154
|
+
object.__setattr__(self, "suggested_action", Recommendation(self.suggested_action))
|
|
155
|
+
|
|
156
|
+
# Coerce tuple enums
|
|
157
|
+
if self.scope:
|
|
158
|
+
object.__setattr__(self, "scope", tuple(Scope(v) for v in self.scope))
|
|
159
|
+
if self.role:
|
|
160
|
+
object.__setattr__(self, "role", tuple(Role(v) for v in self.role))
|
|
161
|
+
if self.action:
|
|
162
|
+
object.__setattr__(self, "action", tuple(Action(v) for v in self.action))
|
|
163
|
+
if self.capability:
|
|
164
|
+
object.__setattr__(self, "capability", tuple(Capability(v) for v in self.capability))
|
|
165
|
+
if self.structure:
|
|
166
|
+
object.__setattr__(self, "structure", tuple(Structure(v) for v in self.structure))
|
|
167
|
+
|
|
168
|
+
# ─── OTel correlation aliases ─────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def trace_id(self) -> str:
|
|
172
|
+
"""OTel alias: session_id → trace_id."""
|
|
173
|
+
return self.session_id
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def span_id(self) -> str:
|
|
177
|
+
"""OTel alias: tool_call_id → span_id."""
|
|
178
|
+
return self.tool_call_id
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def parent_span_id(self) -> str | None:
|
|
182
|
+
"""OTel alias: parent_tool_call_id → parent_span_id."""
|
|
183
|
+
return self.parent_tool_call_id
|
|
184
|
+
|
|
185
|
+
# ─── Lifecycle checks ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def classified(self) -> bool:
|
|
189
|
+
"""True if enricher has run."""
|
|
190
|
+
return self.stage in (TraceStage.CLASSIFIED, TraceStage.ASSESSED)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def assessed(self) -> bool:
|
|
194
|
+
"""True if scorer has run."""
|
|
195
|
+
return self.stage == TraceStage.ASSESSED
|
|
196
|
+
|
|
197
|
+
# ─── Mutation helpers (frozen dataclass — returns new instance) ────────────
|
|
198
|
+
|
|
199
|
+
def with_classification(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
mechanism=_UNSET,
|
|
203
|
+
effect=_UNSET,
|
|
204
|
+
scope=_UNSET,
|
|
205
|
+
role=_UNSET,
|
|
206
|
+
action=_UNSET,
|
|
207
|
+
capability=_UNSET,
|
|
208
|
+
structure=_UNSET,
|
|
209
|
+
canonical_tool=_UNSET,
|
|
210
|
+
) -> EventTrace:
|
|
211
|
+
"""Return a new EventTrace with classification fields populated.
|
|
212
|
+
|
|
213
|
+
Sentinel-based: omitted fields preserve existing values.
|
|
214
|
+
"""
|
|
215
|
+
kwargs: dict[str, Any] = {}
|
|
216
|
+
if mechanism is not _UNSET:
|
|
217
|
+
kwargs["mechanism"] = mechanism
|
|
218
|
+
if effect is not _UNSET:
|
|
219
|
+
kwargs["effect"] = effect
|
|
220
|
+
if scope is not _UNSET:
|
|
221
|
+
kwargs["scope"] = scope
|
|
222
|
+
if role is not _UNSET:
|
|
223
|
+
kwargs["role"] = role
|
|
224
|
+
if action is not _UNSET:
|
|
225
|
+
kwargs["action"] = action
|
|
226
|
+
if capability is not _UNSET:
|
|
227
|
+
kwargs["capability"] = capability
|
|
228
|
+
if structure is not _UNSET:
|
|
229
|
+
kwargs["structure"] = structure
|
|
230
|
+
if canonical_tool is not _UNSET:
|
|
231
|
+
kwargs["canonical_tool"] = canonical_tool
|
|
232
|
+
return replace(self, stage=TraceStage.CLASSIFIED, **kwargs)
|
|
233
|
+
|
|
234
|
+
def with_assessment(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
risk_score=_UNSET,
|
|
238
|
+
risk_band=_UNSET,
|
|
239
|
+
suggested_action=_UNSET,
|
|
240
|
+
reason=_UNSET,
|
|
241
|
+
) -> EventTrace:
|
|
242
|
+
"""Return a new EventTrace with assessment fields populated.
|
|
243
|
+
|
|
244
|
+
Sentinel-based: omitted fields preserve existing values.
|
|
245
|
+
"""
|
|
246
|
+
kwargs: dict[str, Any] = {}
|
|
247
|
+
if risk_score is not _UNSET:
|
|
248
|
+
kwargs["risk_score"] = risk_score
|
|
249
|
+
if risk_band is not _UNSET:
|
|
250
|
+
kwargs["risk_band"] = risk_band
|
|
251
|
+
if suggested_action is not _UNSET:
|
|
252
|
+
kwargs["suggested_action"] = suggested_action
|
|
253
|
+
if reason is not _UNSET:
|
|
254
|
+
kwargs["reason"] = reason
|
|
255
|
+
return replace(self, stage=TraceStage.ASSESSED, **kwargs)
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def create(
|
|
259
|
+
cls,
|
|
260
|
+
*,
|
|
261
|
+
id: str,
|
|
262
|
+
kind: EventKind | str,
|
|
263
|
+
session_id: str,
|
|
264
|
+
tool_call_id: str,
|
|
265
|
+
timestamp: datetime,
|
|
266
|
+
source_key: str,
|
|
267
|
+
raw_event: dict[str, Any],
|
|
268
|
+
parent_tool_call_id: str | None = None,
|
|
269
|
+
tool_name: str | None = None,
|
|
270
|
+
tool_input: dict[str, Any] | MappingProxyType | None = None,
|
|
271
|
+
tool_result: str | None = None,
|
|
272
|
+
target_resource: str | None = None,
|
|
273
|
+
) -> EventTrace:
|
|
274
|
+
"""Factory for adapter use.
|
|
275
|
+
|
|
276
|
+
__post_init__ handles deep-freeze + enum coercion on all paths.
|
|
277
|
+
"""
|
|
278
|
+
return cls(
|
|
279
|
+
id=id,
|
|
280
|
+
kind=kind,
|
|
281
|
+
session_id=session_id,
|
|
282
|
+
tool_call_id=tool_call_id,
|
|
283
|
+
timestamp=timestamp,
|
|
284
|
+
source_key=source_key,
|
|
285
|
+
raw_event=raw_event,
|
|
286
|
+
parent_tool_call_id=parent_tool_call_id,
|
|
287
|
+
tool_name=tool_name,
|
|
288
|
+
tool_input=tool_input or EMPTY_MAP,
|
|
289
|
+
tool_result=tool_result,
|
|
290
|
+
target_resource=target_resource,
|
|
291
|
+
)
|