glaip-sdk 0.0.20__py3-none-any.whl → 0.1.1__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 (65) hide show
  1. glaip_sdk/_version.py +1 -3
  2. glaip_sdk/branding.py +2 -6
  3. glaip_sdk/cli/agent_config.py +2 -6
  4. glaip_sdk/cli/auth.py +11 -30
  5. glaip_sdk/cli/commands/agents.py +64 -107
  6. glaip_sdk/cli/commands/configure.py +12 -36
  7. glaip_sdk/cli/commands/mcps.py +25 -63
  8. glaip_sdk/cli/commands/models.py +2 -4
  9. glaip_sdk/cli/commands/tools.py +22 -35
  10. glaip_sdk/cli/commands/update.py +3 -8
  11. glaip_sdk/cli/config.py +1 -3
  12. glaip_sdk/cli/display.py +4 -12
  13. glaip_sdk/cli/io.py +8 -14
  14. glaip_sdk/cli/main.py +10 -30
  15. glaip_sdk/cli/mcp_validators.py +5 -15
  16. glaip_sdk/cli/pager.py +3 -9
  17. glaip_sdk/cli/parsers/json_input.py +11 -22
  18. glaip_sdk/cli/resolution.py +3 -9
  19. glaip_sdk/cli/rich_helpers.py +1 -3
  20. glaip_sdk/cli/slash/agent_session.py +5 -10
  21. glaip_sdk/cli/slash/prompt.py +3 -10
  22. glaip_sdk/cli/slash/session.py +46 -98
  23. glaip_sdk/cli/transcript/cache.py +6 -19
  24. glaip_sdk/cli/transcript/capture.py +6 -20
  25. glaip_sdk/cli/transcript/launcher.py +1 -3
  26. glaip_sdk/cli/transcript/viewer.py +187 -46
  27. glaip_sdk/cli/update_notifier.py +165 -21
  28. glaip_sdk/cli/utils.py +33 -85
  29. glaip_sdk/cli/validators.py +11 -12
  30. glaip_sdk/client/_agent_payloads.py +10 -30
  31. glaip_sdk/client/agents.py +33 -63
  32. glaip_sdk/client/base.py +6 -22
  33. glaip_sdk/client/mcps.py +1 -3
  34. glaip_sdk/client/run_rendering.py +121 -24
  35. glaip_sdk/client/tools.py +8 -24
  36. glaip_sdk/client/validators.py +20 -48
  37. glaip_sdk/exceptions.py +1 -3
  38. glaip_sdk/icons.py +9 -3
  39. glaip_sdk/models.py +14 -33
  40. glaip_sdk/payload_schemas/agent.py +1 -3
  41. glaip_sdk/utils/agent_config.py +4 -14
  42. glaip_sdk/utils/client_utils.py +7 -21
  43. glaip_sdk/utils/display.py +2 -6
  44. glaip_sdk/utils/general.py +1 -3
  45. glaip_sdk/utils/import_export.py +3 -9
  46. glaip_sdk/utils/rendering/formatting.py +52 -12
  47. glaip_sdk/utils/rendering/models.py +17 -8
  48. glaip_sdk/utils/rendering/renderer/__init__.py +1 -5
  49. glaip_sdk/utils/rendering/renderer/base.py +1107 -320
  50. glaip_sdk/utils/rendering/renderer/config.py +3 -5
  51. glaip_sdk/utils/rendering/renderer/debug.py +4 -14
  52. glaip_sdk/utils/rendering/renderer/panels.py +1 -3
  53. glaip_sdk/utils/rendering/renderer/progress.py +3 -11
  54. glaip_sdk/utils/rendering/renderer/stream.py +10 -22
  55. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  56. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  57. glaip_sdk/utils/rendering/steps.py +899 -25
  58. glaip_sdk/utils/resource_refs.py +4 -13
  59. glaip_sdk/utils/serialization.py +14 -46
  60. glaip_sdk/utils/validation.py +4 -4
  61. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.1.dist-info}/METADATA +12 -1
  62. glaip_sdk-0.1.1.dist-info/RECORD +82 -0
  63. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  64. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.1.dist-info}/WHEEL +0 -0
  65. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.1.dist-info}/entry_points.txt +0 -0
@@ -39,9 +39,7 @@ class ResourceValidator:
39
39
  if len(found_tools) == 1:
40
40
  return str(found_tools[0].id)
41
41
  elif len(found_tools) > 1:
42
- raise AmbiguousResourceError(
43
- f"Multiple tools found with name '{tool_name}': {[t.id for t in found_tools]}"
44
- )
42
+ raise AmbiguousResourceError(f"Multiple tools found with name '{tool_name}': {[t.id for t in found_tools]}")
45
43
  else:
46
44
  raise NotFoundError(f"Tool not found: {tool_name}")
47
45
 
@@ -51,9 +49,7 @@ class ResourceValidator:
51
49
  if len(found_tools) == 1:
52
50
  return str(found_tools[0].id)
53
51
  elif len(found_tools) > 1:
54
- raise AmbiguousResourceError(
55
- f"Multiple tools found with name '{tool.name}': {[t.id for t in found_tools]}"
56
- )
52
+ raise AmbiguousResourceError(f"Multiple tools found with name '{tool.name}': {[t.id for t in found_tools]}")
57
53
  else:
58
54
  raise NotFoundError(f"Tool not found: {tool.name}")
59
55
 
@@ -73,9 +69,7 @@ class ResourceValidator:
73
69
  elif hasattr(tool, "name") and tool.name is not None:
74
70
  return self._resolve_tool_by_name_attribute(tool, client)
75
71
  else:
76
- raise ValidationError(
77
- f"Invalid tool reference: {tool} - must have 'id' or 'name' attribute"
78
- )
72
+ raise ValidationError(f"Invalid tool reference: {tool} - must have 'id' or 'name' attribute")
79
73
 
80
74
  def _process_single_tool(self, tool: str | Tool, client: Any) -> str:
81
75
  """Process a single tool reference and return its ID."""
@@ -99,22 +93,14 @@ class ResourceValidator:
99
93
  try:
100
94
  tool_id = cls()._process_single_tool(tool, client)
101
95
  tool_ids.append(tool_id)
102
- except (AmbiguousResourceError, NotFoundError) as e:
96
+ except (AmbiguousResourceError, NotFoundError) as err:
103
97
  # Determine the tool name for the error message
104
- tool_name = (
105
- tool if isinstance(tool, str) else getattr(tool, "name", str(tool))
106
- )
107
- raise ValidationError(
108
- f"Failed to resolve tool name '{tool_name}' to ID: {e}"
109
- )
110
- except Exception as e:
98
+ tool_name = tool if isinstance(tool, str) else getattr(tool, "name", str(tool))
99
+ raise ValidationError(f"Failed to resolve tool name '{tool_name}' to ID: {err}") from err
100
+ except Exception as err:
111
101
  # For other exceptions, wrap them appropriately
112
- tool_name = (
113
- tool if isinstance(tool, str) else getattr(tool, "name", str(tool))
114
- )
115
- raise ValidationError(
116
- f"Failed to resolve tool name '{tool_name}' to ID: {e}"
117
- )
102
+ tool_name = tool if isinstance(tool, str) else getattr(tool, "name", str(tool))
103
+ raise ValidationError(f"Failed to resolve tool name '{tool_name}' to ID: {err}") from err
118
104
 
119
105
  return tool_ids
120
106
 
@@ -158,9 +144,7 @@ class ResourceValidator:
158
144
  elif hasattr(agent, "name") and agent.name is not None:
159
145
  return self._resolve_agent_by_name_attribute(agent, client)
160
146
  else:
161
- raise ValidationError(
162
- f"Invalid agent reference: {agent} - must have 'id' or 'name' attribute"
163
- )
147
+ raise ValidationError(f"Invalid agent reference: {agent} - must have 'id' or 'name' attribute")
164
148
 
165
149
  def _process_single_agent(self, agent: str | Any, client: Any) -> str:
166
150
  """Process a single agent reference and return its ID."""
@@ -184,26 +168,14 @@ class ResourceValidator:
184
168
  try:
185
169
  agent_id = cls()._process_single_agent(agent, client)
186
170
  agent_ids.append(agent_id)
187
- except (AmbiguousResourceError, NotFoundError) as e:
171
+ except (AmbiguousResourceError, NotFoundError) as err:
188
172
  # Determine the agent name for the error message
189
- agent_name = (
190
- agent
191
- if isinstance(agent, str)
192
- else getattr(agent, "name", str(agent))
193
- )
194
- raise ValidationError(
195
- f"Failed to resolve agent name '{agent_name}' to ID: {e}"
196
- )
197
- except Exception as e:
173
+ agent_name = agent if isinstance(agent, str) else getattr(agent, "name", str(agent))
174
+ raise ValidationError(f"Failed to resolve agent name '{agent_name}' to ID: {err}") from err
175
+ except Exception as err:
198
176
  # For other exceptions, wrap them appropriately
199
- agent_name = (
200
- agent
201
- if isinstance(agent, str)
202
- else getattr(agent, "name", str(agent))
203
- )
204
- raise ValidationError(
205
- f"Failed to resolve agent name '{agent_name}' to ID: {e}"
206
- )
177
+ agent_name = agent if isinstance(agent, str) else getattr(agent, "name", str(agent))
178
+ raise ValidationError(f"Failed to resolve agent name '{agent_name}' to ID: {err}") from err
207
179
 
208
180
  return agent_ids
209
181
 
@@ -213,8 +185,8 @@ class ResourceValidator:
213
185
  for tool_id in tool_ids:
214
186
  try:
215
187
  client.get_tool_by_id(tool_id)
216
- except NotFoundError:
217
- raise ValidationError(f"Tool not found: {tool_id}")
188
+ except NotFoundError as err:
189
+ raise ValidationError(f"Tool not found: {tool_id}") from err
218
190
 
219
191
  @classmethod
220
192
  def validate_agents_exist(cls, agent_ids: list[str], client: Any) -> None:
@@ -222,5 +194,5 @@ class ResourceValidator:
222
194
  for agent_id in agent_ids:
223
195
  try:
224
196
  client.get_agent_by_id(agent_id)
225
- except NotFoundError:
226
- raise ValidationError(f"Agent not found: {agent_id}")
197
+ except NotFoundError as err:
198
+ raise ValidationError(f"Agent not found: {agent_id}") from err
glaip_sdk/exceptions.py CHANGED
@@ -107,9 +107,7 @@ class AgentTimeoutError(TimeoutError):
107
107
  agent_name: Optional name of the agent that timed out
108
108
  """
109
109
  agent_info = f" for agent '{agent_name}'" if agent_name else ""
110
- message = (
111
- f"Agent execution timed out after {timeout_seconds} seconds{agent_info}"
112
- )
110
+ message = f"Agent execution timed out after {timeout_seconds} seconds{agent_info}"
113
111
  super().__init__(message)
114
112
  self.timeout_seconds = timeout_seconds
115
113
  self.agent_name = agent_name
glaip_sdk/icons.py CHANGED
@@ -5,10 +5,13 @@ Authors:
5
5
  """
6
6
 
7
7
  ICON_AGENT = "🤖"
8
- ICON_AGENT_STEP = "🧠"
8
+ ICON_AGENT_STEP = "🤖"
9
9
  ICON_TOOL = "🔧"
10
- ICON_TOOL_STEP = "⚙️"
11
- ICON_DELEGATE = ICON_AGENT
10
+ ICON_TOOL_STEP = "🔧"
11
+ ICON_DELEGATE = ICON_AGENT_STEP
12
+ ICON_STATUS_SUCCESS = "✓"
13
+ ICON_STATUS_FAILED = "✗"
14
+ ICON_STATUS_WARNING = "⚠"
12
15
 
13
16
  __all__ = [
14
17
  "ICON_AGENT",
@@ -16,4 +19,7 @@ __all__ = [
16
19
  "ICON_TOOL",
17
20
  "ICON_TOOL_STEP",
18
21
  "ICON_DELEGATE",
22
+ "ICON_STATUS_SUCCESS",
23
+ "ICON_STATUS_FAILED",
24
+ "ICON_STATUS_WARNING",
19
25
  ]
glaip_sdk/models.py CHANGED
@@ -13,6 +13,9 @@ from pydantic import BaseModel
13
13
 
14
14
  from glaip_sdk.config.constants import DEFAULT_AGENT_RUN_TIMEOUT
15
15
 
16
+ _AGENT_CLIENT_REQUIRED_MSG = "No client available. Use client.get_agent_by_id() to get a client-connected agent."
17
+ _MCP_CLIENT_REQUIRED_MSG = "No client available. Use client.get_mcp_by_id() to get a client-connected MCP."
18
+
16
19
 
17
20
  class Agent(BaseModel):
18
21
  """Agent model for API responses."""
@@ -27,9 +30,7 @@ class Agent(BaseModel):
27
30
  tools: list[dict[str, Any]] | None = None # Backend returns ToolReference objects
28
31
  agents: list[dict[str, Any]] | None = None # Backend returns AgentReference objects
29
32
  mcps: list[dict[str, Any]] | None = None # Backend returns MCPReference objects
30
- tool_configs: dict[str, Any] | None = (
31
- None # Backend returns tool configurations keyed by tool UUID
32
- )
33
+ tool_configs: dict[str, Any] | None = None # Backend returns tool configurations keyed by tool UUID
33
34
  agent_config: dict[str, Any] | None = None
34
35
  timeout: int = DEFAULT_AGENT_RUN_TIMEOUT
35
36
  metadata: dict[str, Any] | None = None
@@ -53,9 +54,7 @@ class Agent(BaseModel):
53
54
  **kwargs: Additional arguments passed to run_agent
54
55
  """
55
56
  if not self._client:
56
- raise RuntimeError(
57
- "No client available. Use client.get_agent_by_id() to get a client-connected agent."
58
- )
57
+ raise RuntimeError(_AGENT_CLIENT_REQUIRED_MSG)
59
58
  # Automatically pass the agent name for better renderer display
60
59
  kwargs.setdefault("agent_name", self.name)
61
60
  # Pass the agent's configured timeout if not explicitly overridden
@@ -80,9 +79,7 @@ class Agent(BaseModel):
80
79
  Exception: For other unexpected errors
81
80
  """
82
81
  if not self._client:
83
- raise RuntimeError(
84
- "No client available. Use client.get_agent_by_id() to get a client-connected agent."
85
- )
82
+ raise RuntimeError(_AGENT_CLIENT_REQUIRED_MSG)
86
83
  # Automatically pass the agent name for better context
87
84
  kwargs.setdefault("agent_name", self.name)
88
85
  # Pass the agent's configured timeout if not explicitly overridden
@@ -95,9 +92,7 @@ class Agent(BaseModel):
95
92
  def update(self, **kwargs) -> "Agent":
96
93
  """Update agent attributes."""
97
94
  if not self._client:
98
- raise RuntimeError(
99
- "No client available. Use client.get_agent_by_id() to get a client-connected agent."
100
- )
95
+ raise RuntimeError(_AGENT_CLIENT_REQUIRED_MSG)
101
96
  updated_agent = self._client.update_agent(self.id, **kwargs)
102
97
  # Update current instance with new data
103
98
  for key, value in updated_agent.model_dump().items():
@@ -108,9 +103,7 @@ class Agent(BaseModel):
108
103
  def delete(self) -> None:
109
104
  """Delete the agent."""
110
105
  if not self._client:
111
- raise RuntimeError(
112
- "No client available. Use client.get_agent_by_id() to get a client-connected agent."
113
- )
106
+ raise RuntimeError(_AGENT_CLIENT_REQUIRED_MSG)
114
107
  self._client.delete_agent(self.id)
115
108
 
116
109
 
@@ -149,16 +142,12 @@ class Tool(BaseModel):
149
142
  Pass 'file' parameter to update tool code via file upload.
150
143
  """
151
144
  if not self._client:
152
- raise RuntimeError(
153
- "No client available. Use client.get_tool_by_id() to get a client-connected tool."
154
- )
145
+ raise RuntimeError("No client available. Use client.get_tool_by_id() to get a client-connected tool.")
155
146
 
156
147
  # Check if file upload is requested
157
148
  if "file" in kwargs:
158
149
  file_path = kwargs.pop("file") # Remove file from kwargs for metadata
159
- updated_tool = self._client.tools.update_tool_via_file(
160
- self.id, file_path, **kwargs
161
- )
150
+ updated_tool = self._client.tools.update_tool_via_file(self.id, file_path, **kwargs)
162
151
  else:
163
152
  # Regular metadata update
164
153
  updated_tool = self._client.tools.update_tool(self.id, **kwargs)
@@ -172,9 +161,7 @@ class Tool(BaseModel):
172
161
  def delete(self) -> None:
173
162
  """Delete the tool."""
174
163
  if not self._client:
175
- raise RuntimeError(
176
- "No client available. Use client.get_tool_by_id() to get a client-connected tool."
177
- )
164
+ raise RuntimeError("No client available. Use client.get_tool_by_id() to get a client-connected tool.")
178
165
  self._client.delete_tool(self.id)
179
166
 
180
167
 
@@ -198,9 +185,7 @@ class MCP(BaseModel):
198
185
  def get_tools(self) -> list[dict[str, Any]]:
199
186
  """Get tools available from this MCP."""
200
187
  if not self._client:
201
- raise RuntimeError(
202
- "No client available. Use client.get_mcp_by_id() to get a client-connected MCP."
203
- )
188
+ raise RuntimeError(_MCP_CLIENT_REQUIRED_MSG)
204
189
  # This would delegate to the client's MCP tools endpoint
205
190
  # For now, return empty list as placeholder
206
191
  return []
@@ -208,9 +193,7 @@ class MCP(BaseModel):
208
193
  def update(self, **kwargs) -> "MCP":
209
194
  """Update MCP attributes."""
210
195
  if not self._client:
211
- raise RuntimeError(
212
- "No client available. Use client.get_mcp_by_id() to get a client-connected MCP."
213
- )
196
+ raise RuntimeError(_MCP_CLIENT_REQUIRED_MSG)
214
197
  updated_mcp = self._client.update_mcp(self.id, **kwargs)
215
198
  # Update current instance with new data
216
199
  for key, value in updated_mcp.model_dump().items():
@@ -221,9 +204,7 @@ class MCP(BaseModel):
221
204
  def delete(self) -> None:
222
205
  """Delete the MCP."""
223
206
  if not self._client:
224
- raise RuntimeError(
225
- "No client available. Use client.get_mcp_by_id() to get a client-connected MCP."
226
- )
207
+ raise RuntimeError("No client available. Use client.get_mcp_by_id() to get a client-connected MCP.")
227
208
  self._client.delete_mcp(self.id)
228
209
 
229
210
 
@@ -60,9 +60,7 @@ AGENT_FIELD_RULES: Mapping[str, FieldRule] = {
60
60
  }
61
61
 
62
62
 
63
- def get_import_field_plan(
64
- field_name: str, operation: AgentImportOperation
65
- ) -> ImportFieldPlan:
63
+ def get_import_field_plan(field_name: str, operation: AgentImportOperation) -> ImportFieldPlan:
66
64
  """Return the import handling plan for ``field_name`` under ``operation``.
67
65
 
68
66
  Unknown fields default to being copied as-is so new API fields propagate
@@ -54,17 +54,11 @@ def sanitize_agent_config(
54
54
  cfg = agent_config or {}
55
55
 
56
56
  if strip_lm_identity and isinstance(cfg, dict):
57
- cfg = {
58
- k: v
59
- for k, v in cfg.items()
60
- if k not in {"lm_provider", "lm_name", "lm_base_url"}
61
- }
57
+ cfg = {k: v for k, v in cfg.items() if k not in {"lm_provider", "lm_name", "lm_base_url"}}
62
58
  return cfg
63
59
 
64
60
 
65
- def resolve_language_model_selection(
66
- merged_data: dict[str, Any], cli_model: str | None
67
- ) -> tuple[dict[str, Any], bool]:
61
+ def resolve_language_model_selection(merged_data: dict[str, Any], cli_model: str | None) -> tuple[dict[str, Any], bool]:
68
62
  """Resolve language model selection from merged data and CLI args.
69
63
 
70
64
  Implements the LM selection priority:
@@ -98,17 +92,13 @@ def resolve_language_model_selection(
98
92
  # Priority 3: Legacy lm_name from agent_config
99
93
  agent_config = merged_data.get("agent_config") or {}
100
94
  if isinstance(agent_config, dict) and agent_config.get("lm_name"):
101
- return {
102
- "model": agent_config["lm_name"]
103
- }, True # Strip LM identity when extracting from agent_config
95
+ return {"model": agent_config["lm_name"]}, True # Strip LM identity when extracting from agent_config
104
96
 
105
97
  # No LM selection found
106
98
  return {}, False
107
99
 
108
100
 
109
- def normalize_agent_config_for_import(
110
- agent_data: dict[str, Any], cli_model: str | None = None
111
- ) -> dict[str, Any]:
101
+ def normalize_agent_config_for_import(agent_data: dict[str, Any], cli_model: str | None = None) -> dict[str, Any]:
112
102
  """Automatically normalize agent configuration by extracting LM settings from agent_config.
113
103
 
114
104
  This function addresses the common issue where exported agent configurations contain
@@ -90,9 +90,7 @@ def extract_ids(items: list[str | Any] | None) -> list[str] | None:
90
90
  return result if result else None
91
91
 
92
92
 
93
- def create_model_instances(
94
- data: list[dict] | None, model_class: type, client: Any
95
- ) -> list[Any]:
93
+ def create_model_instances(data: list[dict] | None, model_class: type, client: Any) -> list[Any]:
96
94
  """Create model instances from API data with client association.
97
95
 
98
96
  This is a common pattern used across different clients (agents, tools, mcps)
@@ -112,9 +110,7 @@ def create_model_instances(
112
110
  return [model_class(**item_data)._set_client(client) for item_data in data]
113
111
 
114
112
 
115
- def find_by_name(
116
- items: list[Any], name: str, case_sensitive: bool = False
117
- ) -> list[Any]:
113
+ def find_by_name(items: list[Any], name: str, case_sensitive: bool = False) -> list[Any]:
118
114
  """Filter items by name with optional case sensitivity.
119
115
 
120
116
  This is a common pattern used across different clients for client-side
@@ -234,9 +230,7 @@ def _handle_streaming_error(
234
230
  if isinstance(e, httpx.ReadTimeout):
235
231
  logger.error(f"Read timeout during streaming: {e}")
236
232
  logger.error("This usually indicates the backend is taking too long to respond")
237
- logger.error(
238
- "Consider increasing the timeout value or checking backend performance"
239
- )
233
+ logger.error("Consider increasing the timeout value or checking backend performance")
240
234
  raise AgentTimeoutError(timeout_seconds or 30.0, agent_name)
241
235
 
242
236
  elif isinstance(e, httpx.TimeoutException):
@@ -275,9 +269,7 @@ def _yield_event_data(event_data: dict[str, Any] | None) -> Iterator[dict[str, A
275
269
  yield event_data
276
270
 
277
271
 
278
- def _flush_remaining_buffer(
279
- buf: list[str], event_type: str | None, event_id: str | None
280
- ) -> Iterator[dict[str, Any]]:
272
+ def _flush_remaining_buffer(buf: list[str], event_type: str | None, event_id: str | None) -> Iterator[dict[str, Any]]:
281
273
  """Flush any remaining data in buffer."""
282
274
  if buf:
283
275
  yield {
@@ -317,9 +309,7 @@ def iter_sse_events(
317
309
  if line is None:
318
310
  continue
319
311
 
320
- buf, event_type, event_id, event_data, completed = _process_sse_line(
321
- line, buf, event_type, event_id
322
- )
312
+ buf, event_type, event_id, event_data, completed = _process_sse_line(line, buf, event_type, event_id)
323
313
 
324
314
  yield from _yield_event_data(event_data)
325
315
  if completed:
@@ -385,9 +375,7 @@ def _create_form_data(message: str) -> dict[str, Any]:
385
375
  return {"input": message, "message": message, "stream": True}
386
376
 
387
377
 
388
- def _prepare_file_entry(
389
- item: str | BinaryIO, stack: ExitStack
390
- ) -> tuple[str, tuple[str, BinaryIO, str]]:
378
+ def _prepare_file_entry(item: str | BinaryIO, stack: ExitStack) -> tuple[str, tuple[str, BinaryIO, str]]:
391
379
  """Prepare a single file entry for multipart data."""
392
380
  if isinstance(item, str):
393
381
  return _prepare_path_entry(item, stack)
@@ -395,9 +383,7 @@ def _prepare_file_entry(
395
383
  return _prepare_stream_entry(item)
396
384
 
397
385
 
398
- def _prepare_path_entry(
399
- path_str: str, stack: ExitStack
400
- ) -> tuple[str, tuple[str, BinaryIO, str]]:
386
+ def _prepare_path_entry(path_str: str, stack: ExitStack) -> tuple[str, tuple[str, BinaryIO, str]]:
401
387
  """Prepare a file path entry."""
402
388
  file_path = Path(path_str)
403
389
  if not file_path.exists():
@@ -109,9 +109,7 @@ def print_agent_updated(agent: Any) -> None:
109
109
  """
110
110
  if RICH_AVAILABLE:
111
111
  console = _create_console()
112
- console.print(
113
- f"[{SUCCESS_STYLE}]✅ Agent '{agent.name}' updated successfully[/]"
114
- )
112
+ console.print(f"[{SUCCESS_STYLE}]✅ Agent '{agent.name}' updated successfully[/]")
115
113
  else:
116
114
  print(f"✅ Agent '{agent.name}' updated successfully")
117
115
 
@@ -124,8 +122,6 @@ def print_agent_deleted(agent_id: str) -> None:
124
122
  """
125
123
  if RICH_AVAILABLE:
126
124
  console = _create_console()
127
- console.print(
128
- f"[{SUCCESS_STYLE}]✅ Agent deleted successfully (ID: {agent_id})[/]"
129
- )
125
+ console.print(f"[{SUCCESS_STYLE}]✅ Agent deleted successfully (ID: {agent_id})[/]")
130
126
  else:
131
127
  print(f"✅ Agent deleted successfully (ID: {agent_id})")
@@ -76,9 +76,7 @@ def format_datetime(dt: datetime | str | None) -> str:
76
76
  return str(dt)
77
77
 
78
78
 
79
- def progress_bar(
80
- iterable: Iterable[Any], description: str = "Processing"
81
- ) -> Iterator[Any]:
79
+ def progress_bar(iterable: Iterable[Any], description: str = "Processing") -> Iterator[Any]:
82
80
  """Simple progress bar using click.
83
81
 
84
82
  Args:
@@ -71,14 +71,10 @@ def _get_default_array_fields() -> list[str]:
71
71
 
72
72
  def _should_use_cli_value(cli_value: Any) -> bool:
73
73
  """Check if CLI value should be used."""
74
- return cli_value is not None and (
75
- not isinstance(cli_value, list | tuple) or len(cli_value) > 0
76
- )
74
+ return cli_value is not None and (not isinstance(cli_value, (list, tuple)) or len(cli_value) > 0)
77
75
 
78
76
 
79
- def _handle_array_field_merge(
80
- key: str, cli_value: Any, import_data: dict[str, Any]
81
- ) -> Any:
77
+ def _handle_array_field_merge(key: str, cli_value: Any, import_data: dict[str, Any]) -> Any:
82
78
  """Handle merging of array fields."""
83
79
  import_value = import_data[key]
84
80
  if isinstance(import_value, list):
@@ -107,9 +103,7 @@ def _merge_cli_values_with_import(
107
103
  merged[key] = import_data[key]
108
104
 
109
105
 
110
- def _add_import_only_fields(
111
- merged: dict[str, Any], import_data: dict[str, Any]
112
- ) -> None:
106
+ def _add_import_only_fields(merged: dict[str, Any], import_data: dict[str, Any]) -> None:
113
107
  """Add fields that exist only in import data."""
114
108
  for key, import_value in import_data.items():
115
109
  if key not in merged:
@@ -12,7 +12,14 @@ import time
12
12
  from collections.abc import Callable
13
13
  from typing import Any
14
14
 
15
- from glaip_sdk.icons import ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
15
+ from glaip_sdk.icons import (
16
+ ICON_AGENT_STEP,
17
+ ICON_DELEGATE,
18
+ ICON_STATUS_FAILED,
19
+ ICON_STATUS_SUCCESS,
20
+ ICON_STATUS_WARNING,
21
+ ICON_TOOL_STEP,
22
+ )
16
23
 
17
24
  # Constants for argument formatting
18
25
  DEFAULT_ARGS_MAX_LEN = 100
@@ -37,9 +44,20 @@ SECRET_VALUE_PATTERNS = [
37
44
  re.compile(r"eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+"), # JWT tokens
38
45
  ]
39
46
  SENSITIVE_PATTERNS = re.compile(
40
- r"password\s*[:=]\s*[^\s,}]+|secret\s*[:=]\s*[^\s,}]+|token\s*[:=]\s*[^\s,}]+|key\s*[:=]\s*[^\s,}]+|api_key\s*[:=]\s*[^\s,}]+|^password$|^secret$|^token$|^key$|^api_key$",
47
+ r"(?:password|secret|token|key|api_key)(?:\s*[:=]\s*[^\s,}]+)?",
41
48
  re.IGNORECASE,
42
49
  )
50
+ CONNECTOR_VERTICAL = "│ "
51
+ CONNECTOR_EMPTY = " "
52
+ CONNECTOR_BRANCH = "├─ "
53
+ CONNECTOR_LAST = "└─ "
54
+ ROOT_MARKER = ""
55
+ SECRET_MASK = "••••••"
56
+ STATUS_GLYPHS = {
57
+ "success": ICON_STATUS_SUCCESS,
58
+ "failed": ICON_STATUS_FAILED,
59
+ "warning": ICON_STATUS_WARNING,
60
+ }
43
61
 
44
62
 
45
63
  def _truncate_string(s: str, max_len: int) -> str:
@@ -53,7 +71,7 @@ def mask_secrets_in_string(text: str) -> str:
53
71
  """Mask sensitive information in a string."""
54
72
  result = text
55
73
  for pattern in SECRET_VALUE_PATTERNS:
56
- result = re.sub(pattern, "••••••", result)
74
+ result = re.sub(pattern, SECRET_MASK, result)
57
75
  return result
58
76
 
59
77
 
@@ -74,7 +92,7 @@ def _redact_dict_values(text: dict) -> dict:
74
92
  result = {}
75
93
  for key, value in text.items():
76
94
  if _is_sensitive_key(key):
77
- result[key] = "••••••"
95
+ result[key] = SECRET_MASK
78
96
  elif _should_recurse_redaction(value):
79
97
  result[key] = redact_sensitive(value)
80
98
  else:
@@ -92,11 +110,11 @@ def _redact_string_content(text: str) -> str:
92
110
  result = text
93
111
  # First mask secrets
94
112
  for pattern in SECRET_VALUE_PATTERNS:
95
- result = re.sub(pattern, "••••••", result)
113
+ result = re.sub(pattern, SECRET_MASK, result)
96
114
  # Then redact sensitive patterns
97
115
  result = re.sub(
98
116
  SENSITIVE_PATTERNS,
99
- lambda m: m.group(0).split("=")[0] + "=••••••",
117
+ lambda m: m.group(0).split("=")[0] + "=" + SECRET_MASK,
100
118
  result,
101
119
  )
102
120
  return result
@@ -105,15 +123,37 @@ def _redact_string_content(text: str) -> str:
105
123
  def _is_sensitive_key(key: str) -> bool:
106
124
  """Check if a key contains sensitive information."""
107
125
  key_lower = key.lower()
108
- return any(
109
- sensitive in key_lower
110
- for sensitive in ["password", "secret", "token", "key", "api_key"]
111
- )
126
+ return any(sensitive in key_lower for sensitive in ["password", "secret", "token", "key", "api_key"])
112
127
 
113
128
 
114
129
  def _should_recurse_redaction(value: Any) -> bool:
115
130
  """Check if a value should be recursively processed."""
116
- return isinstance(value, dict | list) or isinstance(value, str)
131
+ return isinstance(value, (dict, list)) or isinstance(value, str)
132
+
133
+
134
+ def glyph_for_status(icon_key: str | None) -> str | None:
135
+ """Return glyph representing a step status icon key."""
136
+ if not icon_key:
137
+ return None
138
+ return STATUS_GLYPHS.get(icon_key)
139
+
140
+
141
+ def normalise_display_label(label: str | None) -> str:
142
+ """Return a user facing label or the Unknown fallback."""
143
+ label = (label or "").strip()
144
+ return label or "Unknown step detail"
145
+
146
+
147
+ def build_connector_prefix(branch_state: tuple[bool, ...]) -> str:
148
+ """Build connector prefix for a tree line based on ancestry state."""
149
+ if not branch_state:
150
+ return ROOT_MARKER
151
+
152
+ parts: list[str] = []
153
+ for ancestor_is_last in branch_state[:-1]:
154
+ parts.append(CONNECTOR_EMPTY if ancestor_is_last else CONNECTOR_VERTICAL)
155
+ parts.append(CONNECTOR_LAST if branch_state[-1] else CONNECTOR_BRANCH)
156
+ return "".join(parts)
117
157
 
118
158
 
119
159
  def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
@@ -132,7 +172,7 @@ def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
132
172
  try:
133
173
  args_str = json.dumps(masked_args, ensure_ascii=False, separators=(",", ":"))
134
174
  return _truncate_string(args_str, max_len)
135
- except (TypeError, ValueError, Exception):
175
+ except Exception:
136
176
  # Fallback to string representation if JSON serialization fails
137
177
  args_str = str(masked_args)
138
178
  return _truncate_string(args_str, max_len)
@@ -30,19 +30,32 @@ class Step:
30
30
  context_id: str | None = None
31
31
  started_at: float = field(default_factory=monotonic)
32
32
  duration_ms: int | None = None
33
-
34
- def finish(self, duration_raw: float | None) -> None:
33
+ duration_source: str | None = None
34
+ display_label: str | None = None
35
+ status_icon: str | None = None
36
+ failure_reason: str | None = None
37
+ branch_failed: bool = False
38
+ is_parallel: bool = False
39
+ server_started_at: float | None = None
40
+ server_finished_at: float | None = None
41
+ duration_unknown: bool = False
42
+
43
+ def finish(self, duration_raw: float | None, *, source: str | None = None) -> None:
35
44
  """Mark the step as finished and calculate duration.
36
45
 
37
46
  Args:
38
47
  duration_raw: Raw duration in seconds, or None to calculate from started_at
48
+ source: Optional duration source tag
39
49
  """
40
- if isinstance(duration_raw, int | float) and duration_raw > 0:
50
+ self.duration_unknown = False
51
+ if isinstance(duration_raw, (int, float)) and duration_raw > 0:
41
52
  # Use provided duration if it's a positive number (even if very small)
42
53
  self.duration_ms = round(float(duration_raw) * 1000)
54
+ self.duration_source = source or self.duration_source or "provided"
43
55
  else:
44
56
  # Calculate from started_at if duration_raw is None, negative, or zero
45
57
  self.duration_ms = int((monotonic() - self.started_at) * 1000)
58
+ self.duration_source = source or self.duration_source or "monotonic"
46
59
  self.status = "finished"
47
60
 
48
61
 
@@ -68,8 +81,4 @@ class RunStats:
68
81
  Returns:
69
82
  Duration in seconds if run is finished, None otherwise
70
83
  """
71
- return (
72
- None
73
- if self.finished_at is None
74
- else round(self.finished_at - self.started_at, 2)
75
- )
84
+ return None if self.finished_at is None else round(self.finished_at - self.started_at, 2)
@@ -35,12 +35,9 @@ def make_silent_renderer() -> RichStreamRenderer:
35
35
  cfg = RendererConfig(
36
36
  live=False,
37
37
  persist_live=False,
38
- show_delegate_tool_panels=False,
39
38
  render_thinking=False,
40
39
  )
41
- return RichStreamRenderer(
42
- console=Console(file=io.StringIO(), force_terminal=False), cfg=cfg
43
- )
40
+ return RichStreamRenderer(console=Console(file=io.StringIO(), force_terminal=False), cfg=cfg)
44
41
 
45
42
 
46
43
  def make_minimal_renderer() -> RichStreamRenderer:
@@ -51,7 +48,6 @@ def make_minimal_renderer() -> RichStreamRenderer:
51
48
  cfg = RendererConfig(
52
49
  live=False,
53
50
  persist_live=False,
54
- show_delegate_tool_panels=False,
55
51
  render_thinking=False,
56
52
  )
57
53
  return RichStreamRenderer(console=Console(), cfg=cfg)