glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__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 (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +271 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,778 @@
1
+ """SSE event processing mixin for StepManager.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Mapping
11
+ from copy import deepcopy
12
+ from time import monotonic
13
+ from typing import Any
14
+
15
+ from glaip_sdk.utils.rendering.models import Step
16
+ from glaip_sdk.utils.rendering.timing import coerce_server_time
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ COERCION_FAILED_KEY = "_meta_coercion_failed_"
22
+
23
+
24
+ class StepEventMixin:
25
+ """Mixin providing SSE event processing capabilities for StepManager.
26
+
27
+ This mixin adds methods to process server-sent events (SSE) and update
28
+ step state accordingly. It handles event parsing, step creation/updates,
29
+ parent-child relationships, and duration tracking.
30
+ """
31
+
32
+ def apply_event(self, event: dict[str, Any]) -> Step:
33
+ """Apply an SSE step event and return the updated step."""
34
+ cloned_events = self._split_multi_tool_event(event)
35
+ if cloned_events:
36
+ last_step: Step | None = None
37
+ for cloned in cloned_events:
38
+ last_step = self._apply_single_event(cloned)
39
+ if last_step:
40
+ return last_step
41
+ return self._apply_single_event(event)
42
+
43
+ def _split_multi_tool_event(self, event: dict[str, Any]) -> list[dict[str, Any]]:
44
+ """Split events that describe multiple tool calls into per-call clones."""
45
+ metadata = event.get("metadata") or {}
46
+ tool_info = metadata.get("tool_info") or {}
47
+ tool_calls = tool_info.get("tool_calls")
48
+ if not self._should_split_tool_calls(tool_calls):
49
+ return []
50
+ if self._all_delegate_calls(tool_calls):
51
+ return []
52
+
53
+ base_step_id = metadata.get("step_id") or "step"
54
+ clones: list[dict[str, Any]] = []
55
+ for index, call in enumerate(tool_calls):
56
+ clone = self._clone_tool_call(event, tool_info, call, base_step_id, index)
57
+ if clone is not None:
58
+ clones.append(clone)
59
+ return clones
60
+
61
+ @staticmethod
62
+ def _should_split_tool_calls(tool_calls: Any) -> bool:
63
+ """Return True when an event references more than one tool call."""
64
+ return isinstance(tool_calls, list) and len(tool_calls) > 1
65
+
66
+ def _all_delegate_calls(self, tool_calls: Any) -> bool:
67
+ """Return True when an event batch only contains delegate tools."""
68
+ if not isinstance(tool_calls, list) or not tool_calls:
69
+ return False
70
+ for call in tool_calls:
71
+ if not isinstance(call, dict):
72
+ return False
73
+ name = (call.get("name") or "").lower()
74
+ if not self._is_delegate_tool(name):
75
+ return False
76
+ return True
77
+
78
+ def _clone_tool_call(
79
+ self,
80
+ event: dict[str, Any],
81
+ tool_info: dict[str, Any],
82
+ call: Any,
83
+ base_step_id: str,
84
+ index: int,
85
+ ) -> dict[str, Any] | None:
86
+ """Create a per-call clone of a multi-tool event."""
87
+ if not isinstance(call, dict):
88
+ return None
89
+
90
+ cloned = deepcopy(event)
91
+ cloned_meta = cloned.setdefault("metadata", {})
92
+ cloned_tool_info = dict(tool_info)
93
+ cloned_tool_info["tool_calls"] = [dict(call)]
94
+ self._copy_tool_call_field(call, cloned_tool_info, "name")
95
+ self._copy_tool_call_field(call, cloned_tool_info, "args")
96
+ self._copy_tool_call_field(call, cloned_tool_info, "id")
97
+ cloned_meta["tool_info"] = cloned_tool_info
98
+ cloned_meta["step_id"] = self._derive_call_step_id(call, base_step_id, index)
99
+ return cloned
100
+
101
+ @staticmethod
102
+ def _copy_tool_call_field(call: dict[str, Any], target: dict[str, Any], field: str) -> None:
103
+ """Copy a field from the tool call when it exists."""
104
+ value = call.get(field)
105
+ if value:
106
+ target[field] = value
107
+
108
+ @staticmethod
109
+ def _derive_call_step_id(call: dict[str, Any], base_step_id: str, index: int) -> str:
110
+ """Determine the per-call step identifier."""
111
+ call_id = call.get("id")
112
+ if isinstance(call_id, str):
113
+ stripped = call_id.strip()
114
+ if stripped:
115
+ return stripped
116
+ return f"{base_step_id}#{index}"
117
+
118
+ def _apply_single_event(self, event: dict[str, Any]) -> Step:
119
+ metadata, step_id, tool_info, args = self._parse_event_payload(event)
120
+ metadata_failed = bool(metadata.pop(COERCION_FAILED_KEY, False))
121
+ tool_name = self._resolve_tool_name(tool_info, metadata, step_id)
122
+ kind = self._derive_step_kind(tool_name, metadata)
123
+ parent_hint = self._coerce_parent_id(metadata.get("previous_step_ids"))
124
+
125
+ step = self._get_or_create_step(
126
+ step_id=step_id,
127
+ kind=kind,
128
+ tool_name=tool_name,
129
+ event=event,
130
+ metadata=metadata,
131
+ args=args,
132
+ )
133
+ parent_id = self._determine_parent_id(step, metadata, parent_hint)
134
+ self._link_step(step, parent_id)
135
+
136
+ self.state.retained_ids.add(step.step_id)
137
+ if metadata_failed:
138
+ step.metadata = {}
139
+ else:
140
+ step.metadata = dict(metadata)
141
+ self._flush_buffered_children(step.step_id)
142
+ self._apply_pending_branch_flags(step.step_id)
143
+
144
+ status = self._normalise_status(metadata.get("status"), event.get("status"), event.get("task_state"))
145
+ status = self._apply_failure_state(step, status, event)
146
+
147
+ server_time = coerce_server_time(metadata.get("time"))
148
+ self._update_server_timestamps(step, server_time, status)
149
+
150
+ self._apply_duration(
151
+ step=step,
152
+ status=status,
153
+ tool_info=tool_info,
154
+ args=args,
155
+ server_time=server_time,
156
+ )
157
+
158
+ self._update_scope_bindings(
159
+ step=step,
160
+ metadata=metadata,
161
+ tool_name=tool_name,
162
+ status=status,
163
+ )
164
+
165
+ self._update_parallel_tracking(step)
166
+ self._update_running_index(step)
167
+ self._prune_steps()
168
+ return step
169
+
170
+ def _parse_event_payload(self, event: dict[str, Any]) -> tuple[dict[str, Any], str, dict[str, Any], dict[str, Any]]:
171
+ metadata_raw = event.get("metadata") or {}
172
+ metadata, metadata_reliable = self._coerce_event_metadata(metadata_raw)
173
+ if not metadata:
174
+ raise ValueError("Step event missing metadata payload")
175
+ metadata[COERCION_FAILED_KEY] = not metadata_reliable
176
+
177
+ step_id = metadata.get("step_id")
178
+ if not isinstance(step_id, str) or not step_id:
179
+ raise ValueError("Step event missing step_id")
180
+
181
+ tool_info = metadata.get("tool_info") or {}
182
+ if not isinstance(tool_info, dict):
183
+ tool_info = {}
184
+
185
+ canonical_step_id = self._canonicalize_step_id(step_id, tool_info)
186
+ metadata["step_id"] = canonical_step_id
187
+ step_id = canonical_step_id
188
+
189
+ args = self._resolve_tool_args(tool_info)
190
+
191
+ return metadata, step_id, tool_info, args
192
+
193
+ def _coerce_event_metadata(self, metadata: Any) -> tuple[dict[str, Any], bool]:
194
+ """Return a dict copy of event metadata with graceful fallbacks."""
195
+ if isinstance(metadata, dict):
196
+ return metadata, True
197
+ if isinstance(metadata, Mapping):
198
+ try:
199
+ return dict(metadata), True
200
+ except Exception:
201
+ logger.debug("Failed to coerce mapping metadata; falling back to key-by-key copy", exc_info=True)
202
+ return self._copy_mapping_fields(metadata), False
203
+ # All other payloads are treated as empty
204
+ return {}, False
205
+
206
+ @staticmethod
207
+ def _copy_mapping_fields(metadata: Mapping[str, Any]) -> dict[str, Any]:
208
+ """Copy known fields from a mapping without iteration."""
209
+ copied: dict[str, Any] = {}
210
+ for key in (
211
+ "step_id",
212
+ "tool_info",
213
+ "status",
214
+ "kind",
215
+ "previous_step_ids",
216
+ "time",
217
+ "agent_name",
218
+ "task_id",
219
+ "context_id",
220
+ ):
221
+ value = metadata.get(key) # type: ignore[attr-defined]
222
+ if value is not None:
223
+ copied[key] = value
224
+ return copied
225
+
226
+ def _resolve_tool_name(self, tool_info: dict[str, Any], metadata: dict[str, Any], step_id: str) -> str:
227
+ name = tool_info.get("name")
228
+ if not name:
229
+ call = self._first_tool_call(tool_info)
230
+ if call:
231
+ name = call.get("name")
232
+ if isinstance(name, str) and name.strip():
233
+ return name
234
+ if name is not None:
235
+ return str(name)
236
+
237
+ agent_name = metadata.get("agent_name")
238
+ if isinstance(agent_name, str) and agent_name.strip():
239
+ return agent_name
240
+ return step_id
241
+
242
+ def _resolve_tool_args(self, tool_info: dict[str, Any]) -> dict[str, Any]:
243
+ args = tool_info.get("args")
244
+ if isinstance(args, dict):
245
+ return args
246
+ call = self._first_tool_call(tool_info)
247
+ if call:
248
+ call_args = call.get("args")
249
+ if isinstance(call_args, dict):
250
+ return call_args
251
+ return {}
252
+
253
+ def _first_tool_call(self, tool_info: dict[str, Any]) -> dict[str, Any] | None:
254
+ tool_calls = tool_info.get("tool_calls")
255
+ if isinstance(tool_calls, list) and tool_calls:
256
+ candidate = tool_calls[0]
257
+ if isinstance(candidate, dict):
258
+ return candidate
259
+ return None
260
+
261
+ def _get_or_create_step(
262
+ self,
263
+ step_id: str,
264
+ kind: str,
265
+ tool_name: str,
266
+ event: dict[str, Any],
267
+ metadata: dict[str, Any],
268
+ args: dict[str, Any],
269
+ ) -> Step:
270
+ existing = self.by_id.get(step_id)
271
+ if existing:
272
+ return self._update_existing_step(existing, kind, tool_name, event, metadata, args)
273
+ return self._create_step_from_event(step_id, kind, tool_name, event, metadata, args)
274
+
275
+ def _create_step_from_event(
276
+ self,
277
+ step_id: str,
278
+ kind: str,
279
+ tool_name: str,
280
+ event: dict[str, Any],
281
+ metadata: dict[str, Any],
282
+ args: dict[str, Any],
283
+ ) -> Step:
284
+ step = Step(
285
+ step_id=step_id,
286
+ kind=kind,
287
+ name=tool_name or step_id,
288
+ task_id=self._coalesce_metadata_value("task_id", event, metadata, fallback=None),
289
+ context_id=self._coalesce_metadata_value("context_id", event, metadata, fallback=None),
290
+ args=args or {},
291
+ )
292
+ self.by_id[step_id] = step
293
+ self.state.retained_ids.add(step_id)
294
+ return step
295
+
296
+ def _update_existing_step(
297
+ self,
298
+ step: Step,
299
+ kind: str,
300
+ tool_name: str,
301
+ event: dict[str, Any],
302
+ metadata: dict[str, Any],
303
+ args: dict[str, Any],
304
+ ) -> Step:
305
+ step.kind = step.kind or kind
306
+ step.name = tool_name or step.name
307
+ if args:
308
+ step.args = args
309
+ step.task_id = self._coalesce_metadata_value("task_id", event, metadata, fallback=step.task_id)
310
+ step.context_id = self._coalesce_metadata_value("context_id", event, metadata, fallback=step.context_id)
311
+ return step
312
+
313
+ def _apply_failure_state(self, step: Step, status: str, event: dict[str, Any]) -> str:
314
+ failure_reason = self._extract_failure_reason(status, event.get("task_state"), event.get("content"))
315
+ if not failure_reason:
316
+ step.status = status
317
+ return status
318
+
319
+ step.failure_reason = failure_reason
320
+ if status not in {"failed", "stopped"}:
321
+ status = "failed"
322
+ self._set_branch_warning(step.parent_id)
323
+ step.status = status
324
+ return status
325
+
326
+ def _apply_duration(
327
+ self,
328
+ step: Step,
329
+ status: str,
330
+ tool_info: dict[str, Any],
331
+ args: dict[str, Any],
332
+ server_time: float | None,
333
+ ) -> None:
334
+ duration_ms, duration_source = self._resolve_duration_from_event(tool_info, args)
335
+ if duration_ms is not None:
336
+ step.duration_ms = duration_ms
337
+ step.duration_source = duration_source
338
+ return
339
+
340
+ if status in {"finished", "failed", "stopped"} and step.duration_ms is None:
341
+ timeline_ms = self._calculate_server_duration(step, server_time)
342
+ if timeline_ms is not None:
343
+ step.duration_ms = timeline_ms
344
+ step.duration_source = "timeline"
345
+ return
346
+ try:
347
+ step.duration_ms = int((monotonic() - step.started_at) * 1000)
348
+ except Exception:
349
+ step.duration_ms = 0
350
+ step.duration_source = step.duration_source or "monotonic"
351
+
352
+ def _update_running_index(self, step: Step) -> None:
353
+ key = (step.task_id, step.context_id, step.kind, step.name)
354
+ if step.status == "finished":
355
+ if self._last_running.get(key) == step.step_id:
356
+ self._last_running.pop(key, None)
357
+ else:
358
+ self._last_running[key] = step.step_id
359
+
360
+ def _coalesce_metadata_value(
361
+ self,
362
+ key: str,
363
+ event: dict[str, Any],
364
+ metadata: dict[str, Any],
365
+ *,
366
+ fallback: Any = None,
367
+ ) -> Any:
368
+ if event.get(key) is not None:
369
+ return event[key]
370
+ if metadata.get(key) is not None:
371
+ return metadata[key]
372
+ return fallback
373
+
374
+ def _coerce_parent_id(self, parent_value: Any) -> str | None:
375
+ if isinstance(parent_value, list):
376
+ for candidate in parent_value:
377
+ if isinstance(candidate, str) and candidate.strip():
378
+ return self._canonical_parent_id(candidate)
379
+ elif isinstance(parent_value, str) and parent_value.strip():
380
+ return self._canonical_parent_id(parent_value)
381
+ return None
382
+
383
+ def _canonical_parent_id(self, value: str) -> str:
384
+ return self._step_aliases.get(value, value)
385
+
386
+ def _derive_step_kind(self, tool_name: str | None, metadata: dict[str, Any]) -> str:
387
+ metadata_kind = metadata.get("kind")
388
+ kind = self._clean_kind(metadata_kind)
389
+ tool = (tool_name or "").lower()
390
+
391
+ if self._is_thinking_step(kind, tool):
392
+ return "thinking"
393
+ if self._is_delegate_tool(tool):
394
+ return "delegate"
395
+ if kind == "agent_thinking_step" and tool:
396
+ return "tool"
397
+ if self._is_top_level_agent(tool_name, metadata, kind):
398
+ return "agent"
399
+ if kind == "agent_step" and tool.startswith("delegate"):
400
+ return "delegate"
401
+ if tool.startswith("agent_"):
402
+ return "agent"
403
+ if kind == "agent_step":
404
+ return "tool" if tool else "agent_step"
405
+ return kind or "tool"
406
+
407
+ def _clean_kind(self, metadata_kind: Any) -> str:
408
+ return metadata_kind.lower() if isinstance(metadata_kind, str) else ""
409
+
410
+ def _is_thinking_step(self, kind: str, tool: str) -> bool:
411
+ if tool.startswith("agent_thinking"):
412
+ return True
413
+ return kind == "agent_thinking_step" and not tool
414
+
415
+ def _is_delegate_tool(self, tool: str) -> bool:
416
+ return tool.startswith(("delegate_to_", "delegate-", "delegate ", "delegate_"))
417
+
418
+ def _is_top_level_agent(self, tool_name: str | None, metadata: dict[str, Any], kind: str) -> bool:
419
+ if kind != "agent_step":
420
+ return False
421
+ agent_name = metadata.get("agent_name")
422
+ if isinstance(agent_name, str) and agent_name and tool_name == agent_name:
423
+ return True
424
+ return self._looks_like_uuid(tool_name or "")
425
+
426
+ @staticmethod
427
+ def _looks_like_uuid(value: str) -> bool:
428
+ stripped = value.replace("-", "")
429
+ if len(stripped) not in {32, 36}:
430
+ return False
431
+ return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
432
+
433
+ def _normalise_status(
434
+ self,
435
+ metadata_status: Any,
436
+ event_status: Any,
437
+ task_state: Any,
438
+ ) -> str:
439
+ for candidate in (metadata_status, event_status, task_state):
440
+ status = (candidate or "").lower() if isinstance(candidate, str) else ""
441
+ if status in {"running", "started", "pending", "working"}:
442
+ return "running"
443
+ if status in {"finished", "success", "succeeded", "completed"}:
444
+ return "finished"
445
+ if status in {"failed", "error"}:
446
+ return "failed"
447
+ if status in {"stopped", "cancelled", "canceled"}:
448
+ return "stopped"
449
+ return "running"
450
+
451
+ def _extract_failure_reason(
452
+ self,
453
+ status: str,
454
+ task_state: Any,
455
+ content: Any,
456
+ ) -> str | None:
457
+ failure_states = {"failed", "stopped", "error"}
458
+ task_state_str = (task_state or "").lower() if isinstance(task_state, str) else ""
459
+ if status in failure_states or task_state_str in failure_states:
460
+ if isinstance(content, str) and content.strip():
461
+ return content.strip()
462
+ if task_state_str:
463
+ return task_state_str
464
+ return None
465
+
466
+ def _resolve_duration_from_event(
467
+ self,
468
+ tool_info: dict[str, Any],
469
+ args: dict[str, Any],
470
+ ) -> tuple[int | None, str | None]:
471
+ exec_time = tool_info.get("execution_time")
472
+ if isinstance(exec_time, (int, float)):
473
+ return max(0, int(round(float(exec_time) * 1000))), "metadata"
474
+
475
+ duration_seconds = tool_info.get("duration_seconds")
476
+ if isinstance(duration_seconds, (int, float)):
477
+ return max(0, int(round(float(duration_seconds) * 1000))), "metadata"
478
+
479
+ wait_seconds = args.get("wait_seconds")
480
+ if isinstance(wait_seconds, (int, float)):
481
+ return max(0, int(round(float(wait_seconds) * 1000))), "argument"
482
+
483
+ return None, None
484
+
485
+ def _determine_parent_id(self, step: Step, metadata: dict[str, Any], parent_hint: str | None) -> str | None:
486
+ scope_parent = self._lookup_scope_parent(metadata, step)
487
+ candidate = scope_parent or parent_hint
488
+ if candidate == step.step_id:
489
+ logger.debug("Step %s cannot parent itself; dropping parent hint", candidate)
490
+ return None
491
+ return candidate
492
+
493
+ def _lookup_scope_parent(self, metadata: dict[str, Any], step: Step) -> str | None:
494
+ agent_name = metadata.get("agent_name")
495
+ if not isinstance(agent_name, str) or not agent_name.strip():
496
+ return None
497
+ stack = self._scope_anchors.get(agent_name.strip())
498
+ if not stack:
499
+ return None
500
+ anchor_id = stack[-1]
501
+ if anchor_id == step.step_id:
502
+ return None
503
+ return anchor_id
504
+
505
+ def _link_step(self, step: Step, parent_id: str | None) -> None:
506
+ """Attach a step to the resolved parent, buffering when necessary."""
507
+ parent_id = self._sanitize_parent_reference(step, parent_id)
508
+ if self._ensure_existing_link(step, parent_id):
509
+ return
510
+
511
+ self._detach_from_current_parent(step)
512
+ self._attach_to_parent(step, parent_id)
513
+
514
+ def _sanitize_parent_reference(self, step: Step, parent_id: str | None) -> str | None:
515
+ """Guard against self-referential parent assignments."""
516
+ if parent_id != step.step_id:
517
+ return parent_id
518
+
519
+ logger.debug(
520
+ "Ignoring self-referential parent_id %s for step %s",
521
+ parent_id,
522
+ step.step_id,
523
+ )
524
+ return step.parent_id
525
+
526
+ def _ensure_existing_link(self, step: Step, parent_id: str | None) -> bool:
527
+ """Keep existing parent/child wiring in sync when the parent is unchanged."""
528
+ if parent_id != step.parent_id:
529
+ return False
530
+
531
+ if parent_id is None:
532
+ if step.step_id not in self.state.root_order:
533
+ self.state.link_root(step.step_id)
534
+ return True
535
+
536
+ if parent_id not in self.by_id:
537
+ return False
538
+
539
+ children = self.children.get(parent_id, [])
540
+ if step.step_id not in children:
541
+ self.state.link_child(parent_id, step.step_id)
542
+ return True
543
+
544
+ def _detach_from_current_parent(self, step: Step) -> None:
545
+ """Remove the step from its current parent/root collection."""
546
+ if step.parent_id:
547
+ self.state.unlink_child(step.parent_id, step.step_id)
548
+ return
549
+ self.state.unlink_root(step.step_id)
550
+
551
+ def _attach_to_parent(self, step: Step, parent_id: str | None) -> None:
552
+ """Attach the step to the requested parent, buffering when needed."""
553
+ if parent_id is None:
554
+ step.parent_id = None
555
+ self.state.link_root(step.step_id)
556
+ return
557
+
558
+ if parent_id not in self.by_id:
559
+ self.state.buffer_child(parent_id, step.step_id)
560
+ step.parent_id = None
561
+ return
562
+
563
+ step.parent_id = parent_id
564
+ self.state.link_child(parent_id, step.step_id)
565
+ self.state.unlink_root(step.step_id)
566
+
567
+ def _update_scope_bindings(
568
+ self,
569
+ *,
570
+ step: Step,
571
+ metadata: dict[str, Any],
572
+ tool_name: str,
573
+ status: str,
574
+ ) -> None:
575
+ agent_name = metadata.get("agent_name")
576
+ if step.kind == "agent" and isinstance(agent_name, str) and agent_name.strip():
577
+ self._register_scope_anchor(agent_name.strip(), step.step_id)
578
+ return
579
+
580
+ if step.kind == "delegate":
581
+ slug = self._derive_delegate_slug(tool_name)
582
+ if not slug:
583
+ return
584
+ # Ensure the delegate anchor exists even if the first event we see is already finished
585
+ if status == "running" or step.step_id not in self._step_scope_map:
586
+ self._register_scope_anchor(slug, step.step_id)
587
+ elif status in {"finished", "failed", "stopped"}:
588
+ self._release_scope_anchor(step.step_id)
589
+ return
590
+
591
+ if status in {"finished", "failed", "stopped"}:
592
+ self._release_scope_anchor(step.step_id)
593
+
594
+ def _register_scope_anchor(self, scope_key: str, step_id: str) -> None:
595
+ scope = scope_key.strip()
596
+ stack = self._scope_anchors.setdefault(scope, [])
597
+ if step_id not in stack:
598
+ stack.append(step_id)
599
+ self._step_scope_map[step_id] = scope
600
+
601
+ def _release_scope_anchor(self, step_id: str) -> None:
602
+ scope = self._step_scope_map.get(step_id)
603
+ if not scope or scope == (self.root_agent_id or "").strip():
604
+ return
605
+ stack = self._scope_anchors.get(scope)
606
+ if stack:
607
+ if stack[-1] == step_id:
608
+ stack.pop()
609
+ elif step_id in stack:
610
+ stack.remove(step_id)
611
+ # Clean up if stack is now empty
612
+ if len(stack) == 0:
613
+ self._scope_anchors.pop(scope, None)
614
+ self._step_scope_map.pop(step_id, None)
615
+
616
+ @staticmethod
617
+ def _derive_delegate_slug(tool_name: str | None) -> str | None:
618
+ if not isinstance(tool_name, str):
619
+ return None
620
+ slug = tool_name.strip()
621
+ if slug.startswith("delegate_to_"):
622
+ slug = slug.removeprefix("delegate_to_")
623
+ elif slug.startswith("delegate_"):
624
+ slug = slug.removeprefix("delegate_")
625
+ elif slug.startswith("delegate-"):
626
+ slug = slug.removeprefix("delegate-")
627
+ slug = slug.replace("-", "_").strip()
628
+ return slug or None
629
+
630
+ def _update_server_timestamps(self, step: Step, server_time: float | None, status: str) -> None:
631
+ if server_time is None:
632
+ return
633
+ if status == "running" and step.server_started_at is None:
634
+ step.server_started_at = server_time
635
+ elif status in {"finished", "failed", "stopped"}:
636
+ step.server_finished_at = server_time
637
+ if step.server_started_at is None:
638
+ step.server_started_at = server_time
639
+
640
+ def _calculate_server_duration(self, step: Step, server_time: float | None) -> int | None:
641
+ start = step.server_started_at
642
+ end = server_time if server_time is not None else step.server_finished_at
643
+ if start is None or end is None:
644
+ return None
645
+ try:
646
+ return max(0, int(round((float(end) - float(start)) * 1000)))
647
+ except Exception:
648
+ return None
649
+
650
+ def _flush_buffered_children(self, parent_id: str) -> None:
651
+ for child_id in self.state.pop_buffered_children(parent_id):
652
+ child = self.by_id.get(child_id)
653
+ if not child:
654
+ continue
655
+ child.parent_id = parent_id
656
+ self.state.link_child(parent_id, child_id)
657
+ self.state.unlink_root(child_id)
658
+
659
+ def _apply_pending_branch_flags(self, step_id: str) -> None:
660
+ if step_id not in self.state.pending_branch_failures:
661
+ return
662
+ step = self.by_id.get(step_id)
663
+ if step:
664
+ step.branch_failed = True
665
+ self.state.pending_branch_failures.discard(step_id)
666
+
667
+ def _set_branch_warning(self, parent_id: str | None) -> None:
668
+ if not parent_id:
669
+ return
670
+ parent = self.by_id.get(parent_id)
671
+ if parent:
672
+ parent.branch_failed = True
673
+ else:
674
+ self.state.pending_branch_failures.add(parent_id)
675
+
676
+ def _update_parallel_tracking(self, step: Step) -> None:
677
+ if step.kind != "tool":
678
+ step.is_parallel = False
679
+ return
680
+
681
+ key = (step.task_id, step.context_id)
682
+ running = self.state.running_by_context.get(key)
683
+
684
+ if step.status == "running":
685
+ if running is None:
686
+ running = set()
687
+ self.state.running_by_context[key] = running
688
+ running.add(step.step_id)
689
+ elif running:
690
+ running.discard(step.step_id)
691
+ step.is_parallel = False
692
+
693
+ if not running:
694
+ self.state.running_by_context.pop(key, None)
695
+ step.is_parallel = False
696
+ return
697
+
698
+ is_parallel = len(running) > 1
699
+ for sid in running:
700
+ current = self.by_id.get(sid)
701
+ if current:
702
+ current.is_parallel = is_parallel
703
+
704
+ def _canonicalize_step_id(self, step_id: str, tool_info: dict[str, Any]) -> str:
705
+ alias = self._lookup_alias(step_id)
706
+ if alias:
707
+ return alias
708
+
709
+ candidate_ids = self._collect_instance_ids(tool_info)
710
+ alias = self._find_existing_candidate_alias(candidate_ids)
711
+ if alias:
712
+ self._step_aliases[step_id] = alias
713
+ return alias
714
+
715
+ return self._register_new_alias(step_id, candidate_ids)
716
+
717
+ def _lookup_alias(self, step_id: str) -> str | None:
718
+ alias = self._step_aliases.get(step_id)
719
+ return alias if alias else None
720
+
721
+ def _find_existing_candidate_alias(self, candidate_ids: list[str]) -> str | None:
722
+ for candidate in candidate_ids:
723
+ mapped = self._step_aliases.get(candidate)
724
+ if mapped:
725
+ return mapped
726
+ return None
727
+
728
+ def _register_new_alias(self, step_id: str, candidate_ids: list[str]) -> str:
729
+ if candidate_ids:
730
+ canonical = step_id if len(candidate_ids) > 1 else candidate_ids[0]
731
+ self._step_aliases[step_id] = canonical
732
+ for candidate in candidate_ids:
733
+ self._step_aliases.setdefault(candidate, canonical)
734
+ return canonical
735
+
736
+ self._step_aliases.setdefault(step_id, step_id)
737
+ return step_id
738
+
739
+ def _collect_instance_ids(self, tool_info: dict[str, Any]) -> list[str]:
740
+ """Collect all potential identifiers for a tool invocation."""
741
+ candidates: list[str] = []
742
+ identifier = self._normalise_identifier(tool_info.get("id"))
743
+ if identifier:
744
+ candidates.append(identifier)
745
+
746
+ candidates.extend(self._extract_tool_call_ids(tool_info.get("tool_calls")))
747
+ return self._deduplicate_candidates(candidates)
748
+
749
+ def _extract_tool_call_ids(self, tool_calls: Any) -> list[str]:
750
+ """Extract unique IDs from tool_calls payloads."""
751
+ if not isinstance(tool_calls, list):
752
+ return []
753
+ collected: list[str] = []
754
+ for call in tool_calls:
755
+ if not isinstance(call, dict):
756
+ continue
757
+ identifier = self._normalise_identifier(call.get("id"))
758
+ if identifier:
759
+ collected.append(identifier)
760
+ return collected
761
+
762
+ @staticmethod
763
+ def _normalise_identifier(value: Any) -> str | None:
764
+ if isinstance(value, str):
765
+ stripped = value.strip()
766
+ return stripped or None
767
+ return None
768
+
769
+ @staticmethod
770
+ def _deduplicate_candidates(candidates: list[str]) -> list[str]:
771
+ seen: set[str] = set()
772
+ ordered: list[str] = []
773
+ for candidate in candidates:
774
+ if candidate in seen:
775
+ continue
776
+ seen.add(candidate)
777
+ ordered.append(candidate)
778
+ return ordered