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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,156 @@
1
+ """Goose preprocessor — flatten nested content_json into typed events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from traceforge.preprocessors.registry import register_preprocessor
9
+
10
+
11
+ @register_preprocessor("goose")
12
+ def preprocess_goose(obj: dict[str, Any]) -> list[dict[str, Any]]:
13
+ """Flatten Goose nested content_json into separate typed events."""
14
+ results = []
15
+ role = obj.get("role")
16
+ content_json_raw = obj.get("content_json")
17
+ ts = obj.get("created_at") or obj.get("created_timestamp")
18
+
19
+ if not content_json_raw:
20
+ return [obj]
21
+
22
+ # Parse content_json if it's a string
23
+ if isinstance(content_json_raw, str):
24
+ try:
25
+ content_items = json.loads(content_json_raw)
26
+ except (json.JSONDecodeError, ValueError):
27
+ return [obj]
28
+ else:
29
+ content_items = content_json_raw
30
+
31
+ if not isinstance(content_items, list):
32
+ return [obj]
33
+
34
+ # Extract nested events from content array
35
+ has_text = False
36
+ for item in content_items:
37
+ if not isinstance(item, dict):
38
+ continue
39
+ item_type = item.get("type", "")
40
+
41
+ if item_type == "text":
42
+ has_text = True
43
+ elif item_type == "toolRequest":
44
+ tool_call = item.get("toolCall", {})
45
+ value = (tool_call.get("value") or {}) if isinstance(tool_call, dict) else {}
46
+ results.append(
47
+ {
48
+ "role": "tool_use",
49
+ "created_at": ts,
50
+ "name": value.get("name", ""),
51
+ "id": item.get("id", ""),
52
+ "input": value.get("arguments", {}),
53
+ }
54
+ )
55
+ elif item_type == "toolResponse":
56
+ tool_result = item.get("toolResult", {})
57
+ results.append(
58
+ {
59
+ "role": "tool_result",
60
+ "created_at": ts,
61
+ "tool_use_id": item.get("id", ""),
62
+ "content": (tool_result.get("value") or {}).get("content", "")
63
+ if isinstance(tool_result, dict)
64
+ else "",
65
+ "is_success": tool_result.get("status") == "success"
66
+ if isinstance(tool_result, dict)
67
+ else False,
68
+ }
69
+ )
70
+ elif item_type == "thinking":
71
+ results.append(
72
+ {
73
+ "role": "thinking",
74
+ "created_at": ts,
75
+ "thinking": item.get("thinking", ""),
76
+ }
77
+ )
78
+ elif item_type == "redactedThinking":
79
+ results.append(
80
+ {
81
+ "role": "redacted_thinking",
82
+ "created_at": ts,
83
+ "data": item.get("data", ""),
84
+ }
85
+ )
86
+ elif item_type == "image":
87
+ results.append(
88
+ {
89
+ "role": "image",
90
+ "created_at": ts,
91
+ "data": item.get("data", ""),
92
+ }
93
+ )
94
+ elif item_type == "toolConfirmationRequest":
95
+ results.append(
96
+ {
97
+ "role": "tool_confirmation_request",
98
+ "created_at": ts,
99
+ "content": item.get("prompt", ""),
100
+ "name": item.get("toolName", ""),
101
+ "id": item.get("id", ""),
102
+ }
103
+ )
104
+ elif item_type == "actionRequired":
105
+ data = item.get("data", {}) if isinstance(item.get("data"), dict) else {}
106
+ # ActionRequiredData sub-variants:
107
+ # Elicitation: {actionType, id, message, requestedSchema}
108
+ # ToolConfirmation: {actionType, id, toolName, arguments, prompt}
109
+ # ElicitationResponse: {actionType, id, userData}
110
+ content = data.get("message") or data.get("prompt") or str(data.get("userData", ""))
111
+ results.append(
112
+ {
113
+ "role": "action_required",
114
+ "created_at": ts,
115
+ "content": content,
116
+ "action_type": data.get("actionType", ""),
117
+ }
118
+ )
119
+ elif item_type == "frontendToolRequest":
120
+ tool_call = item.get("toolCall", {})
121
+ value = (tool_call.get("value") or {}) if isinstance(tool_call, dict) else {}
122
+ results.append(
123
+ {
124
+ "role": "frontend_tool_request",
125
+ "created_at": ts,
126
+ "name": value.get("name", ""),
127
+ "id": item.get("id", ""),
128
+ "input": value.get("arguments", {}),
129
+ }
130
+ )
131
+ elif item_type == "systemNotification":
132
+ results.append(
133
+ {
134
+ "role": "system_notification",
135
+ "created_at": ts,
136
+ "content": item.get("msg", ""),
137
+ }
138
+ )
139
+
140
+ # Always emit the message itself (with role)
141
+ if has_text or not results:
142
+ text_parts = [
143
+ i.get("text", "")
144
+ for i in content_items
145
+ if isinstance(i, dict) and i.get("type") == "text"
146
+ ]
147
+ results.insert(
148
+ 0,
149
+ {
150
+ "role": role,
151
+ "created_at": ts,
152
+ "content": "\n".join(text_parts) if text_parts else content_json_raw,
153
+ },
154
+ )
155
+
156
+ return results
@@ -0,0 +1,84 @@
1
+ """MAF transcript preprocessor — normalize Activity JSONL into typed events.
2
+
3
+ The Microsoft 365 Agents SDK `FileTranscriptStore` writes one Activity per JSONL
4
+ line. Activities use `type` ("message", "typing", "event", "invoke", etc.) and
5
+ `from.role` ("bot", "user") to distinguish direction. This preprocessor emits a
6
+ compound `_event_type` field of the form `{type}.{from_role}` so the YAML mapping
7
+ can discriminate assistant vs user messages.
8
+
9
+ Expected input shape (each JSONL line parsed as dict):
10
+ {
11
+ "type": "message",
12
+ "text": "I'll look into that for you.",
13
+ "timestamp": "2025-01-15T10:30:00Z",
14
+ "from": {"id": "bot-id", "name": "Agent", "role": "bot"},
15
+ "recipient": {"id": "user-id", "name": "User", "role": "user"},
16
+ "conversation": {"id": "conv-123"},
17
+ "channel_id": "directline",
18
+ "id": "activity-456",
19
+ ...
20
+ }
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from traceforge.preprocessors.registry import register_preprocessor
28
+
29
+
30
+ @register_preprocessor("maf_transcript")
31
+ def preprocess_maf_transcript(obj: dict[str, Any]) -> list[dict[str, Any]]:
32
+ """Flatten MAF Activity dict into a single event with compound type."""
33
+ activity_type = obj.get("type", "unknown")
34
+
35
+ # Determine sender role — normalize to lowercase
36
+ from_obj = obj.get("from") or obj.get("from_") or {}
37
+ from_role = ""
38
+ if isinstance(from_obj, dict):
39
+ from_role = (from_obj.get("role") or "").lower()
40
+
41
+ # Compound type: e.g. "message.bot", "message.user", "typing.bot", "event.bot"
42
+ event_type = f"{activity_type}.{from_role}" if from_role else activity_type
43
+
44
+ # Flatten key fields to top level for YAML mapping
45
+ result: dict[str, Any] = {
46
+ "_event_type": event_type,
47
+ "timestamp": obj.get("timestamp"),
48
+ "text": obj.get("text"),
49
+ "activity_id": obj.get("id"),
50
+ "channel_id": obj.get("channel_id"),
51
+ }
52
+
53
+ # Conversation ID
54
+ conversation = obj.get("conversation")
55
+ if isinstance(conversation, dict):
56
+ result["conversation_id"] = conversation.get("id")
57
+
58
+ # From metadata
59
+ if isinstance(from_obj, dict):
60
+ result["from_id"] = from_obj.get("id")
61
+ result["from_name"] = from_obj.get("name")
62
+ result["from_role"] = from_obj.get("role")
63
+
64
+ # Recipient metadata
65
+ recipient = obj.get("recipient")
66
+ if isinstance(recipient, dict):
67
+ result["recipient_id"] = recipient.get("id")
68
+ result["recipient_name"] = recipient.get("name")
69
+
70
+ # Attachments (tool inputs/outputs often come as attachments)
71
+ attachments = obj.get("attachments")
72
+ if attachments and isinstance(attachments, list):
73
+ result["attachments"] = attachments
74
+ result["attachment_count"] = len(attachments)
75
+
76
+ # Value field (used for invoke activities — tool calls, adaptive cards)
77
+ if obj.get("value") is not None:
78
+ result["value"] = obj["value"]
79
+
80
+ # Channel data (framework-specific extensions)
81
+ if obj.get("channel_data") is not None:
82
+ result["channel_data"] = obj["channel_data"]
83
+
84
+ return [result]
@@ -0,0 +1,86 @@
1
+ """OpenAI Agents SDK preprocessor — normalize trace/span exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from traceforge.preprocessors.registry import register_preprocessor
8
+
9
+
10
+ @register_preprocessor("openai_agents")
11
+ def preprocess_openai_agents(obj: dict[str, Any]) -> list[dict[str, Any]]:
12
+ """Flatten native Trace.export()/Span.export() rows into typed events."""
13
+ object_type = obj.get("object")
14
+
15
+ # Passthrough already-typed rows (re-ingested events or generic conformance
16
+ # probes that arrive keyed by the post-preprocessor type_field).
17
+ if object_type is None and obj.get("event_type"):
18
+ return [obj]
19
+
20
+ if object_type == "trace":
21
+ return [
22
+ {
23
+ "event_type": "trace",
24
+ "trace_id": obj.get("id"),
25
+ "workflow_name": obj.get("workflow_name"),
26
+ "group_id": obj.get("group_id"),
27
+ "metadata": obj.get("metadata"),
28
+ }
29
+ ]
30
+
31
+ if object_type != "trace.span":
32
+ return []
33
+
34
+ span_data = obj.get("span_data") or {}
35
+ if not isinstance(span_data, dict):
36
+ return []
37
+
38
+ span_type = span_data.get("type")
39
+ base = {
40
+ "span_id": obj.get("id"),
41
+ "trace_id": obj.get("trace_id"),
42
+ "parent_id": obj.get("parent_id"),
43
+ "started_at": obj.get("started_at"),
44
+ "ended_at": obj.get("ended_at"),
45
+ "error": obj.get("error"),
46
+ "span_data": span_data,
47
+ }
48
+
49
+ if span_type == "function":
50
+ started = {
51
+ **base,
52
+ "event_type": "function.started",
53
+ "timestamp": obj.get("started_at"),
54
+ "tool_name": span_data.get("name"),
55
+ "arguments": span_data.get("input"),
56
+ }
57
+ finished_type = "function.failed" if obj.get("error") else "function.completed"
58
+ finished = {
59
+ **base,
60
+ "event_type": finished_type,
61
+ "timestamp": obj.get("ended_at") or obj.get("started_at"),
62
+ "tool_name": span_data.get("name"),
63
+ "result": span_data.get("output"),
64
+ }
65
+ return [started, finished]
66
+
67
+ event_type_by_span = {
68
+ "agent": "agent.completed",
69
+ "generation": "generation.completed",
70
+ "response": "response.completed",
71
+ "handoff": "handoff.completed",
72
+ "guardrail": "guardrail.completed",
73
+ "custom": "custom.completed",
74
+ "speech": "speech.completed",
75
+ "speech_group": "speech_group.completed",
76
+ "transcription": "transcription.completed",
77
+ "mcp_tools": "mcp_tools.completed",
78
+ }
79
+ event_type = event_type_by_span.get(str(span_type), f"{span_type}.completed")
80
+ return [
81
+ {
82
+ **base,
83
+ "event_type": event_type,
84
+ "timestamp": obj.get("ended_at") or obj.get("started_at"),
85
+ }
86
+ ]
@@ -0,0 +1,85 @@
1
+ """OpenCode preprocessor — normalize v1.17 event-sourced SQLite rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ from traceforge.preprocessors.registry import register_preprocessor
10
+
11
+ _VERSION_SUFFIX_RE = re.compile(r"\.\d+$")
12
+ _MESSAGE_ROLES: dict[tuple[str, str], str] = {}
13
+
14
+
15
+ @register_preprocessor("opencode")
16
+ def preprocess_opencode(obj: dict[str, Any]) -> list[dict[str, Any]]:
17
+ """Normalize OpenCode's SQLite `event` rows to stable mapping types."""
18
+ data = obj.get("data")
19
+ if isinstance(data, str):
20
+ try:
21
+ data = json.loads(data)
22
+ except (json.JSONDecodeError, TypeError):
23
+ data = {}
24
+ if not isinstance(data, dict):
25
+ data = {}
26
+
27
+ norm = dict(obj)
28
+ norm["data"] = data
29
+ if "properties" not in norm and data:
30
+ norm["properties"] = data
31
+ norm["type"] = _VERSION_SUFFIX_RE.sub("", str(obj.get("type", "")))
32
+ norm["_timestamp"] = _timestamp(data, norm)
33
+
34
+ if norm["type"] == "message.updated":
35
+ info = data.get("info") if isinstance(data.get("info"), dict) else {}
36
+ session_id = str(data.get("sessionID") or info.get("sessionID") or "")
37
+ message_id = str(info.get("id") or "")
38
+ role = str(info.get("role") or "unknown")
39
+ if session_id and message_id and role != "unknown":
40
+ _MESSAGE_ROLES[(session_id, message_id)] = role
41
+ norm["type"] = f"message.updated.{role}"
42
+ norm["message_role"] = role
43
+ return [norm]
44
+
45
+ if norm["type"] == "message.part.updated":
46
+ part = data.get("part") if isinstance(data.get("part"), dict) else {}
47
+ part_type = str(part.get("type") or "unknown")
48
+ session_id = str(data.get("sessionID") or part.get("sessionID") or "")
49
+ message_id = str(part.get("messageID") or "")
50
+ role = _MESSAGE_ROLES.get((session_id, message_id), "unknown")
51
+ norm["message_role"] = role
52
+
53
+ if part_type == "text" and role in {"user", "assistant"}:
54
+ norm["type"] = f"message.part.text.{role}"
55
+ elif part_type == "tool":
56
+ state = part.get("state") if isinstance(part.get("state"), dict) else {}
57
+ status = str(state.get("status") or "unknown")
58
+ if status == "pending":
59
+ # OpenCode emits a provisional tool row before input/timing exists;
60
+ # the following running/completed rows carry the canonical signal.
61
+ return []
62
+ norm["type"] = f"message.part.tool.{status}"
63
+ else:
64
+ norm["type"] = f"message.part.{part_type}"
65
+ return [norm]
66
+
67
+ return [norm]
68
+
69
+
70
+ def _timestamp(data: dict[str, Any], obj: dict[str, Any]) -> Any:
71
+ props = obj.get("properties") if isinstance(obj.get("properties"), dict) else {}
72
+ info = data.get("info") if isinstance(data.get("info"), dict) else {}
73
+ info_time = info.get("time") if isinstance(info.get("time"), dict) else {}
74
+ part = data.get("part") if isinstance(data.get("part"), dict) else {}
75
+ part_time = part.get("time") if isinstance(part.get("time"), dict) else {}
76
+
77
+ return (
78
+ data.get("timestamp")
79
+ or data.get("time")
80
+ or info_time.get("updated")
81
+ or info_time.get("created")
82
+ or part_time.get("end")
83
+ or part_time.get("start")
84
+ or props.get("timestamp")
85
+ )
@@ -0,0 +1,36 @@
1
+ """OpenHands preprocessor — compound action/observation discriminator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from traceforge.preprocessors.registry import register_preprocessor
8
+
9
+
10
+ @register_preprocessor("openhands")
11
+ def preprocess_openhands(obj: dict[str, Any]) -> list[dict[str, Any]]:
12
+ """Normalize OpenHands compound discriminator (legacy or SDK events).
13
+
14
+ OpenHands 0.x used an ``action``/``observation`` compound discriminator.
15
+ OpenHands 1.x SDK persistence uses ``kind`` plus ``source``/``tool_name``.
16
+ Synthesize the YAML ``action`` discriminator for both formats while
17
+ preserving the native nested payload for dot-path extraction.
18
+ """
19
+ kind = obj.get("kind")
20
+ if kind in {"SystemPromptEvent", "ActionEvent", "ObservationEvent", "MessageEvent"}:
21
+ normalized = dict(obj)
22
+ if kind in {"ActionEvent", "ObservationEvent"}:
23
+ normalized["action"] = f"{kind}.{obj.get('tool_name', 'unknown')}"
24
+ elif kind == "MessageEvent":
25
+ normalized["action"] = f"MessageEvent.{obj.get('source', 'unknown')}"
26
+ else:
27
+ normalized["action"] = kind
28
+ return [normalized]
29
+
30
+ if "action" in obj:
31
+ return [obj]
32
+ elif "observation" in obj:
33
+ normalized = dict(obj)
34
+ normalized["action"] = f"observation.{normalized['observation']}"
35
+ return [normalized]
36
+ return [obj]
@@ -0,0 +1,62 @@
1
+ """PydanticAI preprocessor — multi-level discrimination to flat type field."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from traceforge.preprocessors.registry import register_preprocessor
8
+
9
+
10
+ @register_preprocessor("pydantic_ai")
11
+ def preprocess_pydantic_ai(obj: dict[str, Any]) -> list[dict[str, Any]]:
12
+ """Normalize PydanticAI multi-level discrimination to flat type field.
13
+
14
+ Preserves nested structure for _resolve_path; only synthesizes the "type"
15
+ discriminator and extracts text content from parts arrays.
16
+ """
17
+ # Stream and callback events have event_kind
18
+ if "event_kind" in obj:
19
+ normalized = dict(obj)
20
+ event_kind = normalized["event_kind"]
21
+
22
+ if event_kind == "function_tool_call":
23
+ normalized["type"] = "tool_call_start"
24
+ elif event_kind == "function_tool_result":
25
+ normalized["type"] = "tool_call_end"
26
+ elif event_kind == "model_response_stream":
27
+ normalized["type"] = "model_response_chunk"
28
+ if "chunk" not in normalized:
29
+ normalized["chunk"] = (normalized.get("part") or {}).get("content", "")
30
+ else:
31
+ normalized["type"] = f"stream.{event_kind}"
32
+ return [normalized]
33
+
34
+ # Messages have kind (request/response)
35
+ kind = obj.get("kind")
36
+ if kind == "response":
37
+ normalized = dict(obj)
38
+ normalized["type"] = "model_response"
39
+ # Extract text from parts for convenience
40
+ parts = normalized.get("parts") or []
41
+ text_parts = [
42
+ p.get("content", "")
43
+ for p in parts
44
+ if isinstance(p, dict) and p.get("part_kind") == "text"
45
+ ]
46
+ if text_parts:
47
+ normalized["content"] = "\n".join(text_parts)
48
+ return [normalized]
49
+ elif kind == "request":
50
+ normalized = dict(obj)
51
+ normalized["type"] = "model_request"
52
+ parts = normalized.get("parts") or []
53
+ user_parts = [
54
+ p.get("content", "")
55
+ for p in parts
56
+ if isinstance(p, dict) and p.get("part_kind") == "user-prompt"
57
+ ]
58
+ if user_parts:
59
+ normalized["content"] = "\n".join(user_parts)
60
+ return [normalized]
61
+
62
+ return [obj]
@@ -0,0 +1,24 @@
1
+ """Preprocessor registry — central registration and lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ PreprocessorFn = Callable[[dict[str, Any]], list[dict[str, Any]]]
9
+ _PREPROCESSORS: dict[str, PreprocessorFn] = {}
10
+
11
+
12
+ def register_preprocessor(name: str) -> Callable[[PreprocessorFn], PreprocessorFn]:
13
+ """Decorator to register a preprocessor function."""
14
+
15
+ def decorator(fn: PreprocessorFn) -> PreprocessorFn:
16
+ _PREPROCESSORS[name] = fn
17
+ return fn
18
+
19
+ return decorator
20
+
21
+
22
+ def get_preprocessor(name: str) -> PreprocessorFn | None:
23
+ """Look up a registered preprocessor by name."""
24
+ return _PREPROCESSORS.get(name)
@@ -0,0 +1,90 @@
1
+ """smolagents preprocessor — infer step type from field presence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from traceforge.preprocessors.registry import register_preprocessor
8
+
9
+
10
+ @register_preprocessor("smolagents")
11
+ def preprocess_smolagents(obj: dict[str, Any]) -> list[dict[str, Any]]:
12
+ """Infer step type from field presence (smolagents has no discriminator).
13
+
14
+ Only synthesizes the step_type field and extracts timestamps from timing.
15
+ If step_type is already present, trusts the existing value.
16
+ Nested structures (token_usage, tool_calls) preserved for _resolve_path.
17
+ """
18
+ normalized = dict(obj)
19
+
20
+ # Extract timestamp from timing dict if present
21
+ timing = normalized.get("timing", {})
22
+ if isinstance(timing, dict) and "start_time" in timing:
23
+ normalized["timestamp"] = timing["start_time"]
24
+
25
+ # If step_type already present (e.g., from callback wrappers), trust it
26
+ if "step_type" in normalized:
27
+ # Still handle tool_calls splitting for ActionStep
28
+ if normalized["step_type"] == "ActionStep":
29
+ tool_calls = normalized.get("tool_calls", [])
30
+ if tool_calls and isinstance(tool_calls, list):
31
+ results: list[dict[str, Any]] = [normalized]
32
+ for tc in tool_calls:
33
+ if isinstance(tc, dict):
34
+ fn = tc.get("function", {})
35
+ results.append(
36
+ {
37
+ "step_type": "ToolCall",
38
+ "timestamp": normalized.get("timestamp"),
39
+ "tool_name": fn.get("name", "") if isinstance(fn, dict) else "",
40
+ "call_id": tc.get("id", ""),
41
+ "tool_input": fn.get("arguments", "")
42
+ if isinstance(fn, dict)
43
+ else "",
44
+ }
45
+ )
46
+ return results
47
+ return [normalized]
48
+
49
+ # Determine step type from field presence
50
+ # Order matters: check most specific first
51
+ if "step_number" in normalized:
52
+ # ActionStep — but check if it's the final answer
53
+ if normalized.get("is_final_answer"):
54
+ # ActionStep with is_final_answer=true: action_output IS the answer
55
+ normalized["step_type"] = "FinalAnswer"
56
+ normalized["output"] = normalized.get("action_output", "")
57
+ else:
58
+ normalized["step_type"] = "ActionStep"
59
+ tool_calls = normalized.get("tool_calls", [])
60
+ if tool_calls and isinstance(tool_calls, list):
61
+ results = [normalized]
62
+ for tc in tool_calls:
63
+ if isinstance(tc, dict):
64
+ fn = tc.get("function", {})
65
+ results.append(
66
+ {
67
+ "step_type": "ToolCall",
68
+ "timestamp": normalized.get("timestamp"),
69
+ "tool_name": fn.get("name", "") if isinstance(fn, dict) else "",
70
+ "call_id": tc.get("id", ""),
71
+ "tool_input": fn.get("arguments", "") if isinstance(fn, dict) else "",
72
+ }
73
+ )
74
+ return results
75
+ elif "plan" in normalized:
76
+ normalized["step_type"] = "PlanningStep"
77
+ elif "system_prompt" in normalized:
78
+ normalized["step_type"] = "SystemPromptStep"
79
+ elif "task" in normalized:
80
+ normalized["step_type"] = "TaskStep"
81
+ elif (
82
+ "output" in normalized
83
+ and len(set(normalized.keys()) - {"output", "timestamp", "step_type"}) == 0
84
+ ):
85
+ # Bare FinalAnswerStep: only has "output" (+ maybe timestamp)
86
+ normalized["step_type"] = "FinalAnswer"
87
+ else:
88
+ normalized["step_type"] = "unknown"
89
+
90
+ return [normalized]
traceforge/py.typed ADDED
File without changes