glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.16__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.
- glaip_sdk/agents/__init__.py +27 -0
- glaip_sdk/agents/base.py +1196 -0
- glaip_sdk/cli/__init__.py +9 -0
- glaip_sdk/cli/account_store.py +540 -0
- glaip_sdk/cli/agent_config.py +78 -0
- glaip_sdk/cli/auth.py +699 -0
- glaip_sdk/cli/commands/__init__.py +5 -0
- glaip_sdk/cli/commands/accounts.py +746 -0
- glaip_sdk/cli/commands/agents.py +1509 -0
- glaip_sdk/cli/commands/common_config.py +104 -0
- glaip_sdk/cli/commands/configure.py +896 -0
- glaip_sdk/cli/commands/mcps.py +1356 -0
- glaip_sdk/cli/commands/models.py +69 -0
- glaip_sdk/cli/commands/tools.py +576 -0
- glaip_sdk/cli/commands/transcripts.py +755 -0
- glaip_sdk/cli/commands/update.py +61 -0
- glaip_sdk/cli/config.py +95 -0
- glaip_sdk/cli/constants.py +38 -0
- glaip_sdk/cli/context.py +150 -0
- glaip_sdk/cli/core/__init__.py +79 -0
- glaip_sdk/cli/core/context.py +124 -0
- glaip_sdk/cli/core/output.py +851 -0
- glaip_sdk/cli/core/prompting.py +649 -0
- glaip_sdk/cli/core/rendering.py +187 -0
- glaip_sdk/cli/display.py +355 -0
- glaip_sdk/cli/hints.py +57 -0
- glaip_sdk/cli/io.py +112 -0
- glaip_sdk/cli/main.py +615 -0
- glaip_sdk/cli/masking.py +136 -0
- glaip_sdk/cli/mcp_validators.py +287 -0
- glaip_sdk/cli/pager.py +266 -0
- glaip_sdk/cli/parsers/__init__.py +7 -0
- glaip_sdk/cli/parsers/json_input.py +177 -0
- glaip_sdk/cli/resolution.py +67 -0
- glaip_sdk/cli/rich_helpers.py +27 -0
- glaip_sdk/cli/slash/__init__.py +15 -0
- glaip_sdk/cli/slash/accounts_controller.py +578 -0
- glaip_sdk/cli/slash/accounts_shared.py +75 -0
- glaip_sdk/cli/slash/agent_session.py +285 -0
- glaip_sdk/cli/slash/prompt.py +256 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
- glaip_sdk/cli/slash/session.py +1708 -0
- glaip_sdk/cli/slash/tui/__init__.py +9 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
- glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
- glaip_sdk/cli/slash/tui/loading.py +58 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
- glaip_sdk/cli/transcript/__init__.py +31 -0
- glaip_sdk/cli/transcript/cache.py +536 -0
- glaip_sdk/cli/transcript/capture.py +329 -0
- glaip_sdk/cli/transcript/export.py +38 -0
- glaip_sdk/cli/transcript/history.py +815 -0
- glaip_sdk/cli/transcript/launcher.py +77 -0
- glaip_sdk/cli/transcript/viewer.py +374 -0
- glaip_sdk/cli/update_notifier.py +290 -0
- glaip_sdk/cli/utils.py +263 -0
- glaip_sdk/cli/validators.py +238 -0
- glaip_sdk/client/__init__.py +11 -0
- glaip_sdk/client/_agent_payloads.py +520 -0
- glaip_sdk/client/agent_runs.py +147 -0
- glaip_sdk/client/agents.py +1335 -0
- glaip_sdk/client/base.py +502 -0
- glaip_sdk/client/main.py +249 -0
- glaip_sdk/client/mcps.py +370 -0
- glaip_sdk/client/run_rendering.py +700 -0
- glaip_sdk/client/shared.py +21 -0
- glaip_sdk/client/tools.py +661 -0
- glaip_sdk/client/validators.py +198 -0
- glaip_sdk/config/constants.py +52 -0
- glaip_sdk/mcps/__init__.py +21 -0
- glaip_sdk/mcps/base.py +345 -0
- glaip_sdk/models/__init__.py +90 -0
- glaip_sdk/models/agent.py +47 -0
- glaip_sdk/models/agent_runs.py +116 -0
- glaip_sdk/models/common.py +42 -0
- glaip_sdk/models/mcp.py +33 -0
- glaip_sdk/models/tool.py +33 -0
- glaip_sdk/payload_schemas/__init__.py +7 -0
- glaip_sdk/payload_schemas/agent.py +85 -0
- glaip_sdk/registry/__init__.py +55 -0
- glaip_sdk/registry/agent.py +164 -0
- glaip_sdk/registry/base.py +139 -0
- glaip_sdk/registry/mcp.py +253 -0
- glaip_sdk/registry/tool.py +232 -0
- glaip_sdk/runner/__init__.py +59 -0
- glaip_sdk/runner/base.py +84 -0
- glaip_sdk/runner/deps.py +112 -0
- glaip_sdk/runner/langgraph.py +782 -0
- glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
- glaip_sdk/runner/tool_adapter/__init__.py +18 -0
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
- glaip_sdk/tools/__init__.py +22 -0
- glaip_sdk/tools/base.py +435 -0
- glaip_sdk/utils/__init__.py +86 -0
- glaip_sdk/utils/a2a/__init__.py +34 -0
- glaip_sdk/utils/a2a/event_processor.py +188 -0
- glaip_sdk/utils/agent_config.py +194 -0
- glaip_sdk/utils/bundler.py +267 -0
- glaip_sdk/utils/client.py +111 -0
- glaip_sdk/utils/client_utils.py +486 -0
- glaip_sdk/utils/datetime_helpers.py +58 -0
- glaip_sdk/utils/discovery.py +78 -0
- glaip_sdk/utils/display.py +135 -0
- glaip_sdk/utils/export.py +143 -0
- glaip_sdk/utils/general.py +61 -0
- glaip_sdk/utils/import_export.py +168 -0
- glaip_sdk/utils/import_resolver.py +492 -0
- glaip_sdk/utils/instructions.py +101 -0
- glaip_sdk/utils/rendering/__init__.py +115 -0
- glaip_sdk/utils/rendering/formatting.py +264 -0
- glaip_sdk/utils/rendering/layout/__init__.py +64 -0
- glaip_sdk/utils/rendering/layout/panels.py +156 -0
- glaip_sdk/utils/rendering/layout/progress.py +202 -0
- glaip_sdk/utils/rendering/layout/summary.py +74 -0
- glaip_sdk/utils/rendering/layout/transcript.py +606 -0
- glaip_sdk/utils/rendering/models.py +85 -0
- glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
- glaip_sdk/utils/rendering/renderer/base.py +1024 -0
- glaip_sdk/utils/rendering/renderer/config.py +27 -0
- glaip_sdk/utils/rendering/renderer/console.py +55 -0
- glaip_sdk/utils/rendering/renderer/debug.py +178 -0
- glaip_sdk/utils/rendering/renderer/factory.py +138 -0
- glaip_sdk/utils/rendering/renderer/stream.py +202 -0
- glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
- glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
- glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
- glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
- glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
- glaip_sdk/utils/rendering/state.py +204 -0
- glaip_sdk/utils/rendering/step_tree_state.py +100 -0
- glaip_sdk/utils/rendering/steps/__init__.py +34 -0
- glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
- glaip_sdk/utils/rendering/steps/format.py +176 -0
- glaip_sdk/utils/rendering/steps/manager.py +387 -0
- glaip_sdk/utils/rendering/timing.py +36 -0
- glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
- glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
- glaip_sdk/utils/resource_refs.py +195 -0
- glaip_sdk/utils/run_renderer.py +41 -0
- glaip_sdk/utils/runtime_config.py +425 -0
- glaip_sdk/utils/serialization.py +424 -0
- glaip_sdk/utils/sync.py +142 -0
- glaip_sdk/utils/tool_detection.py +33 -0
- glaip_sdk/utils/validation.py +264 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/METADATA +4 -5
- glaip_sdk-0.6.16.dist-info/RECORD +160 -0
- glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/top_level.txt +0 -0
|
@@ -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
|