glaip-sdk 0.0.17__py3-none-any.whl → 0.0.18__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.
@@ -194,28 +194,43 @@ def _iter_command_completions(
194
194
  prefix = text[1:]
195
195
  seen: set[str] = set()
196
196
 
197
- if (
198
- session.get_contextual_commands()
199
- and not session.should_include_global_commands()
200
- ):
197
+ # Early return for contextual commands scenario
198
+ if not _should_include_commands(session):
201
199
  return []
202
200
 
203
201
  commands = sorted(session._unique_commands.values(), key=lambda c: c.name)
204
202
 
205
203
  for cmd in commands:
206
- for alias in (cmd.name, *cmd.aliases):
207
- if alias in seen or alias.startswith("?"):
208
- continue
209
- if prefix and not alias.startswith(prefix):
210
- continue
211
- seen.add(alias)
212
- label = f"/{alias}"
213
- yield Completion(
214
- text=label,
215
- start_position=-len(text),
216
- display=label,
217
- display_meta=cmd.help,
218
- )
204
+ yield from _generate_command_completions(cmd, prefix, text, seen)
205
+
206
+
207
+ def _should_include_commands(session: SlashSession) -> bool:
208
+ """Check if commands should be included in completions."""
209
+ return not (
210
+ session.get_contextual_commands()
211
+ and not session.should_include_global_commands()
212
+ )
213
+
214
+
215
+ def _generate_command_completions(
216
+ cmd: Any, prefix: str, text: str, seen: set[str]
217
+ ) -> Iterable[Completion]:
218
+ """Generate completion items for a single command."""
219
+ for alias in (cmd.name, *cmd.aliases):
220
+ if alias in seen or alias.startswith("?"):
221
+ continue
222
+
223
+ if prefix and not alias.startswith(prefix):
224
+ continue
225
+
226
+ seen.add(alias)
227
+ label = f"/{alias}"
228
+ yield Completion(
229
+ text=label,
230
+ start_position=-len(text),
231
+ display=label,
232
+ display_meta=cmd.help,
233
+ )
219
234
 
220
235
 
221
236
  def _iter_contextual_completions(
@@ -297,51 +297,80 @@ class SlashSession:
297
297
  return True
298
298
 
299
299
  def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
300
+ client = self._get_client_or_fail()
301
+ if not client:
302
+ return True
303
+
304
+ agents = self._get_agents_or_fail(client)
305
+ if not agents:
306
+ return True
307
+
308
+ picked_agent = self._resolve_or_pick_agent(client, agents, args)
309
+
310
+ if not picked_agent:
311
+ return True
312
+
313
+ return self._run_agent_session(picked_agent)
314
+
315
+ def _get_client_or_fail(self) -> Any:
316
+ """Get client or handle failure and return None."""
300
317
  try:
301
- client = self._get_client()
318
+ return self._get_client()
302
319
  except click.ClickException as exc:
303
320
  self.console.print(f"[red]{exc}[/red]")
304
- return True
321
+ return None
305
322
 
323
+ def _get_agents_or_fail(self, client: Any) -> list:
324
+ """Get agents list or handle failure and return empty list."""
306
325
  try:
307
326
  agents = client.list_agents()
327
+ if not agents:
328
+ self._handle_no_agents()
329
+ return agents
308
330
  except Exception as exc: # pragma: no cover - API failures
309
331
  self.console.print(f"[red]Failed to load agents: {exc}[/red]")
310
- return True
332
+ return []
311
333
 
312
- if not agents:
313
- hint = command_hint("agents create", slash_command=None, ctx=self.ctx)
314
- if hint:
315
- self.console.print(
316
- f"[yellow]No agents available. Use `{hint}` to add one.[/yellow]"
317
- )
318
- else:
319
- self.console.print("[yellow]No agents available.[/yellow]")
320
- return True
334
+ def _handle_no_agents(self) -> None:
335
+ """Handle case when no agents are available."""
336
+ hint = command_hint("agents create", slash_command=None, ctx=self.ctx)
337
+ if hint:
338
+ self.console.print(
339
+ f"[yellow]No agents available. Use `{hint}` to add one.[/yellow]"
340
+ )
341
+ else:
342
+ self.console.print("[yellow]No agents available.[/yellow]")
321
343
 
344
+ def _resolve_or_pick_agent(self, client: Any, agents: list, args: list[str]) -> Any:
345
+ """Resolve agent from args or pick interactively."""
322
346
  if args:
323
347
  picked_agent = self._resolve_agent_from_ref(client, agents, args[0])
324
348
  if picked_agent is None:
325
349
  self.console.print(
326
350
  f"[yellow]Could not resolve agent '{args[0]}'. Try `/agents` to browse interactively.[/yellow]"
327
351
  )
328
- return True
352
+ return None
329
353
  else:
330
354
  picked_agent = _fuzzy_pick_for_resources(agents, "agent", "")
331
355
 
332
- if not picked_agent:
333
- return True
356
+ return picked_agent
334
357
 
358
+ def _run_agent_session(self, picked_agent: Any) -> bool:
359
+ """Run agent session and show follow-up actions."""
335
360
  self._remember_agent(picked_agent)
336
-
337
361
  AgentRunSession(self, picked_agent).run()
338
362
 
339
- # Refresh the main palette header and surface follow-up actions so the
340
- # user has immediate cues after leaving the agent context.
363
+ # Refresh the main palette header and surface follow-up actions
341
364
  self._render_header()
342
365
 
366
+ self._show_agent_followup_actions(picked_agent)
367
+ return True
368
+
369
+ def _show_agent_followup_actions(self, picked_agent: Any) -> None:
370
+ """Show follow-up action hints after agent session."""
343
371
  agent_id = str(getattr(picked_agent, "id", ""))
344
372
  agent_label = getattr(picked_agent, "name", "") or agent_id or "this agent"
373
+
345
374
  hints: list[tuple[str, str]] = []
346
375
  if agent_id:
347
376
  hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
@@ -351,8 +380,8 @@ class SlashSession:
351
380
  (self.STATUS_COMMAND, "Check connection"),
352
381
  ]
353
382
  )
383
+
354
384
  self._show_quick_actions(hints, title="Next actions")
355
- return True
356
385
 
357
386
  def _cmd_exit(self, _args: list[str], invoked_from_agent: bool) -> bool:
358
387
  if invoked_from_agent:
@@ -710,12 +739,12 @@ class SlashSession:
710
739
 
711
740
  if full:
712
741
  lines = [
713
- self._branding.get_welcome_banner(),
714
- "",
715
- f"[dim]API URL[/dim]: {api_url or 'Not configured'}",
716
- f"[dim]Credentials[/dim]: {status}",
717
- f"[dim]Verbose mode[/dim]: {'on' if self._verbose_enabled else 'off'}",
718
- "[dim]Tip[/dim]: Press Ctrl+T or run `/verbose` to toggle verbose streaming.",
742
+ f"GL AIP v{self._branding.version} · GDP Labs AI Agents Package",
743
+ f"API: {api_url or 'Not configured'} · Credentials: {status}",
744
+ (
745
+ f"Verbose: {'on' if self._verbose_enabled else 'off'} "
746
+ "(Ctrl+T toggles verbose streaming)"
747
+ ),
719
748
  ]
720
749
  extra: list[str] = []
721
750
  self._add_agent_info_to_header(extra, active_agent)
@@ -64,6 +64,7 @@ logger = logging.getLogger("glaip_sdk.agents")
64
64
 
65
65
  _SERVER_ONLY_IMPORT_FIELDS = set(list_server_only_fields()) | {"success", "message"}
66
66
  _MERGED_SEQUENCE_FIELDS = ("tools", "agents", "mcps")
67
+ _DEFAULT_METADATA_TYPE = "custom"
67
68
 
68
69
 
69
70
  def _normalise_sequence(value: Any) -> list[Any] | None:
@@ -119,6 +120,20 @@ def _split_known_and_extra(
119
120
  return known, extras
120
121
 
121
122
 
123
+ def _prepare_agent_metadata(value: Any) -> dict[str, Any]:
124
+ """Ensure agent metadata contains ``type: custom`` by default."""
125
+ if value is None:
126
+ return {"type": _DEFAULT_METADATA_TYPE}
127
+ if not isinstance(value, Mapping):
128
+ return {"type": _DEFAULT_METADATA_TYPE}
129
+
130
+ prepared = dict(value)
131
+ metadata_type = prepared.get("type")
132
+ if not metadata_type:
133
+ prepared["type"] = _DEFAULT_METADATA_TYPE
134
+ return prepared
135
+
136
+
122
137
  def _load_agent_file_payload(
123
138
  file_path: Path, *, model_override: str | None
124
139
  ) -> dict[str, Any]:
@@ -151,16 +166,39 @@ def _prepare_import_payload(
151
166
  overrides_dict = dict(overrides)
152
167
 
153
168
  raw_definition = load_resource_from_file(file_path)
154
- original_refs = {
169
+ original_refs = _extract_original_refs(raw_definition)
170
+
171
+ base_payload = _load_agent_file_payload(
172
+ file_path, model_override=overrides_dict.get("model")
173
+ )
174
+
175
+ cli_args = _build_cli_args(overrides_dict)
176
+
177
+ merged = merge_import_with_cli_args(base_payload, cli_args)
178
+
179
+ additional = _build_additional_args(overrides_dict, cli_args)
180
+ merged.update(additional)
181
+
182
+ if drop_model_fields:
183
+ _remove_model_fields_if_needed(merged, overrides_dict)
184
+
185
+ _set_default_refs(merged, original_refs)
186
+
187
+ _normalise_sequence_fields(merged)
188
+ return merged
189
+
190
+
191
+ def _extract_original_refs(raw_definition: dict) -> dict[str, list]:
192
+ """Extract original tool/agent/mcp references from raw definition."""
193
+ return {
155
194
  "tools": list(raw_definition.get("tools") or []),
156
195
  "agents": list(raw_definition.get("agents") or []),
157
196
  "mcps": list(raw_definition.get("mcps") or []),
158
197
  }
159
198
 
160
- base_payload = _load_agent_file_payload(
161
- file_path, model_override=overrides_dict.get("model")
162
- )
163
199
 
200
+ def _build_cli_args(overrides_dict: dict) -> dict[str, Any]:
201
+ """Build CLI args from overrides, filtering out None values."""
164
202
  cli_args = {
165
203
  key: overrides_dict.get(key)
166
204
  for key in (
@@ -175,32 +213,37 @@ def _prepare_import_payload(
175
213
  if overrides_dict.get(key) is not None
176
214
  }
177
215
 
216
+ # Normalize sequence fields
178
217
  for field in _MERGED_SEQUENCE_FIELDS:
179
218
  if field in cli_args:
180
219
  cli_args[field] = tuple(_normalise_sequence(cli_args[field]) or [])
181
220
 
182
- merged = merge_import_with_cli_args(base_payload, cli_args)
221
+ return cli_args
183
222
 
184
- additional = {
223
+
224
+ def _build_additional_args(overrides_dict: dict, cli_args: dict) -> dict[str, Any]:
225
+ """Build additional args not already in CLI args."""
226
+ return {
185
227
  key: value
186
228
  for key, value in overrides_dict.items()
187
229
  if value is not None and key not in cli_args
188
230
  }
189
- merged.update(additional)
190
231
 
191
- if drop_model_fields:
192
- if overrides_dict.get("language_model_id") is None:
193
- merged.pop("language_model_id", None)
194
- if overrides_dict.get("provider") is None:
195
- merged.pop("provider", None)
196
232
 
233
+ def _remove_model_fields_if_needed(merged: dict, overrides_dict: dict) -> None:
234
+ """Remove model fields if not explicitly overridden."""
235
+ if overrides_dict.get("language_model_id") is None:
236
+ merged.pop("language_model_id", None)
237
+ if overrides_dict.get("provider") is None:
238
+ merged.pop("provider", None)
239
+
240
+
241
+ def _set_default_refs(merged: dict, original_refs: dict) -> None:
242
+ """Set default references if not already present."""
197
243
  merged.setdefault("_tool_refs", original_refs["tools"])
198
244
  merged.setdefault("_agent_refs", original_refs["agents"])
199
245
  merged.setdefault("_mcp_refs", original_refs["mcps"])
200
246
 
201
- _normalise_sequence_fields(merged)
202
- return merged
203
-
204
247
 
205
248
  class AgentClient(BaseClient):
206
249
  """Client for agent operations."""
@@ -594,6 +637,25 @@ class AgentClient(BaseClient):
594
637
  resolved_agents = self._resolve_agent_ids(agents_raw, agent_refs)
595
638
  resolved_mcps = self._resolve_mcp_ids(mcps_raw, mcp_refs)
596
639
 
640
+ language_model_id = known.pop("language_model_id", None)
641
+ provider = known.pop("provider", None)
642
+ model_name = known.pop("model_name", None)
643
+
644
+ agent_type_value = known.pop("agent_type", None)
645
+ fallback_type_value = known.pop("type", None)
646
+ if agent_type_value is None:
647
+ agent_type_value = fallback_type_value or DEFAULT_AGENT_TYPE
648
+
649
+ framework_value = known.pop("framework", None) or DEFAULT_AGENT_FRAMEWORK
650
+ version_value = known.pop("version", None) or DEFAULT_AGENT_VERSION
651
+ account_id = known.pop("account_id", None)
652
+ description = known.pop("description", None)
653
+ metadata = _prepare_agent_metadata(known.pop("metadata", None))
654
+ tool_configs = known.pop("tool_configs", None)
655
+ agent_config = known.pop("agent_config", None)
656
+ timeout_value = known.pop("timeout", None)
657
+ a2a_profile = known.pop("a2a_profile", None)
658
+
597
659
  final_extras = {**known, **extras}
598
660
  final_extras.setdefault("model", resolved_model)
599
661
 
@@ -601,22 +663,22 @@ class AgentClient(BaseClient):
601
663
  name=str(name).strip(),
602
664
  instruction=validated_instruction,
603
665
  model=resolved_model,
604
- language_model_id=known.pop("language_model_id", None),
605
- provider=known.pop("provider", None),
606
- model_name=known.pop("model_name", None),
607
- agent_type=known.pop("agent_type", known.pop("type", DEFAULT_AGENT_TYPE)),
608
- framework=known.pop("framework", None) or DEFAULT_AGENT_FRAMEWORK,
609
- version=known.pop("version", None) or DEFAULT_AGENT_VERSION,
610
- account_id=known.pop("account_id", None),
611
- description=known.pop("description", None),
612
- metadata=known.pop("metadata", None),
666
+ language_model_id=language_model_id,
667
+ provider=provider,
668
+ model_name=model_name,
669
+ agent_type=agent_type_value,
670
+ framework=framework_value,
671
+ version=version_value,
672
+ account_id=account_id,
673
+ description=description,
674
+ metadata=metadata,
613
675
  tools=resolved_tools,
614
676
  agents=resolved_agents,
615
677
  mcps=resolved_mcps,
616
- tool_configs=known.pop("tool_configs", None),
617
- agent_config=known.pop("agent_config", None),
618
- timeout=known.pop("timeout", None) or DEFAULT_AGENT_RUN_TIMEOUT,
619
- a2a_profile=known.pop("a2a_profile", None),
678
+ tool_configs=tool_configs,
679
+ agent_config=agent_config,
680
+ timeout=timeout_value or DEFAULT_AGENT_RUN_TIMEOUT,
681
+ a2a_profile=a2a_profile,
620
682
  extras=final_extras,
621
683
  )
622
684
 
glaip_sdk/client/main.py CHANGED
@@ -158,9 +158,9 @@ class Client(BaseClient):
158
158
  """Get tool script content."""
159
159
  return self.tools.get_tool_script(tool_id)
160
160
 
161
- def update_tool_via_file(self, tool_id: str, file_path: str) -> Tool:
161
+ def update_tool_via_file(self, tool_id: str, file_path: str, **kwargs) -> Tool:
162
162
  """Update tool via file."""
163
- return self.tools.update_tool_via_file(tool_id, file_path)
163
+ return self.tools.update_tool_via_file(tool_id, file_path, **kwargs)
164
164
 
165
165
  # MCPs
166
166
  def create_mcp(self, **kwargs) -> MCP:
glaip_sdk/client/tools.py CHANGED
@@ -241,7 +241,15 @@ class ToolClient(BaseClient):
241
241
  self, update_data: dict[str, Any], kwargs: dict[str, Any]
242
242
  ) -> None:
243
243
  """Handle additional kwargs in update payload."""
244
- excluded_keys = {"tags", "framework", "version"}
244
+ excluded_keys = {
245
+ "tags",
246
+ "framework",
247
+ "version",
248
+ "type",
249
+ "tool_type",
250
+ "name",
251
+ "description",
252
+ }
245
253
  for key, value in kwargs.items():
246
254
  if key not in excluded_keys:
247
255
  update_data[key] = value
@@ -269,9 +277,19 @@ class ToolClient(BaseClient):
269
277
  - Handles metadata updates properly
270
278
  """
271
279
  # Prepare the update payload with current values as defaults
280
+ type_override = kwargs.pop("type", None)
281
+ if type_override is None:
282
+ type_override = kwargs.pop("tool_type", None)
283
+ current_type = (
284
+ type_override
285
+ or getattr(current_tool, "tool_type", None)
286
+ or getattr(current_tool, "type", None)
287
+ or DEFAULT_TOOL_TYPE
288
+ )
289
+
272
290
  update_data = {
273
291
  "name": name if name is not None else current_tool.name,
274
- "type": DEFAULT_TOOL_TYPE, # Required by backend
292
+ "type": current_type,
275
293
  "framework": kwargs.get(
276
294
  "framework", getattr(current_tool, "framework", DEFAULT_TOOL_FRAMEWORK)
277
295
  ),
@@ -476,6 +494,19 @@ class ToolClient(BaseClient):
476
494
  # Validate file exists
477
495
  self._validate_and_read_file(file_path)
478
496
 
497
+ # Fetch current metadata to ensure required fields are preserved
498
+ current_tool = self.get_tool_by_id(tool_id)
499
+
500
+ payload_kwargs = kwargs.copy()
501
+ name = payload_kwargs.pop("name", None)
502
+ description = payload_kwargs.pop("description", None)
503
+ update_payload = self._build_update_payload(
504
+ current_tool=current_tool,
505
+ name=name,
506
+ description=description,
507
+ **payload_kwargs,
508
+ )
509
+
479
510
  try:
480
511
  # Prepare multipart upload
481
512
  with open(file_path, "rb") as fb:
@@ -491,7 +522,7 @@ class ToolClient(BaseClient):
491
522
  "PUT",
492
523
  TOOLS_UPLOAD_BY_ID_ENDPOINT_FMT.format(tool_id=tool_id),
493
524
  files=files,
494
- data=kwargs, # Pass kwargs directly as data
525
+ data=update_payload,
495
526
  )
496
527
 
497
528
  return Tool(**response)._set_client(self)
@@ -134,25 +134,51 @@ def normalize_agent_config_for_import(
134
134
  if not isinstance(agent_config, dict):
135
135
  return normalized_data
136
136
 
137
- # Priority 1: CLI --model flag (highest priority)
137
+ # Apply normalization based on priority order
138
138
  if cli_model:
139
- # When CLI model is specified, set it and don't extract from agent_config
140
- normalized_data["model"] = cli_model
141
- return normalized_data
139
+ return _apply_cli_model_override(normalized_data, cli_model)
142
140
 
143
- # Priority 2: language_model_id already exists - clean up agent_config
144
141
  if normalized_data.get("language_model_id"):
145
- # If language_model_id exists, we should still clean up any conflicting
146
- # LM settings from agent_config to prevent backend validation errors
147
- if isinstance(agent_config, dict):
148
- # Remove LM identity keys from agent_config since language_model_id takes precedence
149
- lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
150
- for key in lm_keys_to_remove:
151
- agent_config.pop(key, None)
152
- normalized_data["agent_config"] = agent_config
142
+ return _cleanup_existing_language_model(normalized_data, agent_config)
143
+
144
+ return _extract_lm_from_agent_config(normalized_data, agent_config)
145
+
146
+
147
+ def _apply_cli_model_override(normalized_data: dict, cli_model: str) -> dict:
148
+ """Apply CLI model override (highest priority)."""
149
+ normalized_data["model"] = cli_model
150
+ return normalized_data
151
+
152
+
153
+ def _cleanup_existing_language_model(normalized_data: dict, agent_config: dict) -> dict:
154
+ """Clean up agent_config when language_model_id already exists."""
155
+ # Remove LM identity keys from agent_config since language_model_id takes precedence
156
+ lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
157
+ for key in lm_keys_to_remove:
158
+ agent_config.pop(key, None)
159
+ normalized_data["agent_config"] = agent_config
160
+ return normalized_data
161
+
162
+
163
+ def _extract_lm_from_agent_config(normalized_data: dict, agent_config: dict) -> dict:
164
+ """Extract LM settings from agent_config (lowest priority)."""
165
+ extracted_lm = _extract_lm_settings(agent_config)
166
+
167
+ if not extracted_lm:
153
168
  return normalized_data
154
169
 
155
- # Priority 3: Extract LM settings from agent_config
170
+ # Add extracted LM settings to top level
171
+ normalized_data.update(extracted_lm)
172
+
173
+ # Create sanitized agent_config (remove extracted LM settings but keep memory)
174
+ sanitized_config = _sanitize_agent_config(agent_config)
175
+ normalized_data["agent_config"] = sanitized_config
176
+
177
+ return normalized_data
178
+
179
+
180
+ def _extract_lm_settings(agent_config: dict) -> dict[str, Any]:
181
+ """Extract LM settings from agent_config."""
156
182
  extracted_lm = {}
157
183
 
158
184
  # Extract lm_name if present
@@ -163,19 +189,16 @@ def normalize_agent_config_for_import(
163
189
  if "lm_provider" in agent_config:
164
190
  extracted_lm["lm_provider"] = agent_config["lm_provider"]
165
191
 
166
- # If we extracted LM settings, update the normalized data
167
- if extracted_lm:
168
- # Add extracted LM settings to top level
169
- normalized_data.update(extracted_lm)
192
+ return extracted_lm
170
193
 
171
- # Create sanitized agent_config (remove extracted LM settings but keep memory)
172
- sanitized_config = agent_config.copy()
173
194
 
174
- # Remove LM identity keys but preserve memory and other settings
175
- lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
176
- for key in lm_keys_to_remove:
177
- sanitized_config.pop(key, None)
195
+ def _sanitize_agent_config(agent_config: dict) -> dict:
196
+ """Create sanitized agent_config by removing LM identity keys."""
197
+ sanitized_config = agent_config.copy()
178
198
 
179
- normalized_data["agent_config"] = sanitized_config
199
+ # Remove LM identity keys but preserve memory and other settings
200
+ lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
201
+ for key in lm_keys_to_remove:
202
+ sanitized_config.pop(key, None)
180
203
 
181
- return normalized_data
204
+ return sanitized_config
@@ -57,41 +57,62 @@ def mask_secrets_in_string(text: str) -> str:
57
57
  def redact_sensitive(text: str | dict | list) -> str | dict | list:
58
58
  """Redact sensitive information in a string, dict, or list."""
59
59
  if isinstance(text, dict):
60
- # Recursively process dictionary values
61
- result = {}
62
- for key, value in text.items():
63
- # Check if the key itself is sensitive
64
- key_lower = key.lower()
65
- if any(
66
- sensitive in key_lower
67
- for sensitive in ["password", "secret", "token", "key", "api_key"]
68
- ):
69
- result[key] = "••••••"
70
- elif isinstance(value, dict | list) or isinstance(value, str):
71
- result[key] = redact_sensitive(value)
72
- else:
73
- result[key] = value
74
- return result
60
+ return _redact_dict_values(text)
75
61
  elif isinstance(text, list):
76
- # Recursively process list items
77
- return [redact_sensitive(item) for item in text]
62
+ return _redact_list_items(text)
78
63
  elif isinstance(text, str):
79
- # Process string - first mask secrets, then redact sensitive patterns
80
- result = text
81
- # First mask secrets
82
- for pattern in SECRET_VALUE_PATTERNS:
83
- result = re.sub(pattern, "••••••", result)
84
- # Then redact sensitive patterns
85
- result = re.sub(
86
- SENSITIVE_PATTERNS,
87
- lambda m: m.group(0).split("=")[0] + "=••••••",
88
- result,
89
- )
90
- return result
64
+ return _redact_string_content(text)
91
65
  else:
92
66
  return text
93
67
 
94
68
 
69
+ def _redact_dict_values(text: dict) -> dict:
70
+ """Recursively process dictionary values and redact sensitive keys."""
71
+ result = {}
72
+ for key, value in text.items():
73
+ if _is_sensitive_key(key):
74
+ result[key] = "••••••"
75
+ elif _should_recurse_redaction(value):
76
+ result[key] = redact_sensitive(value)
77
+ else:
78
+ result[key] = value
79
+ return result
80
+
81
+
82
+ def _redact_list_items(text: list) -> list:
83
+ """Recursively process list items."""
84
+ return [redact_sensitive(item) for item in text]
85
+
86
+
87
+ def _redact_string_content(text: str) -> str:
88
+ """Process string - first mask secrets, then redact sensitive patterns."""
89
+ result = text
90
+ # First mask secrets
91
+ for pattern in SECRET_VALUE_PATTERNS:
92
+ result = re.sub(pattern, "••••••", result)
93
+ # Then redact sensitive patterns
94
+ result = re.sub(
95
+ SENSITIVE_PATTERNS,
96
+ lambda m: m.group(0).split("=")[0] + "=••••••",
97
+ result,
98
+ )
99
+ return result
100
+
101
+
102
+ def _is_sensitive_key(key: str) -> bool:
103
+ """Check if a key contains sensitive information."""
104
+ key_lower = key.lower()
105
+ return any(
106
+ sensitive in key_lower
107
+ for sensitive in ["password", "secret", "token", "key", "api_key"]
108
+ )
109
+
110
+
111
+ def _should_recurse_redaction(value: Any) -> bool:
112
+ """Check if a value should be recursively processed."""
113
+ return isinstance(value, dict | list) or isinstance(value, str)
114
+
115
+
95
116
  def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
96
117
  """Format arguments in a pretty way."""
97
118
  if not args:
@@ -45,16 +45,21 @@ from glaip_sdk.utils.rendering.steps import StepManager
45
45
  # Configure logger
46
46
  logger = logging.getLogger("glaip_sdk.run_renderer")
47
47
 
48
+ # Constants
49
+ LESS_THAN_1MS = "[<1ms]"
50
+
48
51
 
49
52
  @dataclass
50
53
  class RendererState:
51
54
  """Internal state for the renderer."""
52
55
 
53
- buffer: list[str] = None
56
+ buffer: list[str] | None = None
54
57
  final_text: str = ""
55
58
  streaming_started_at: float | None = None
56
59
  printed_final_panel: bool = False
57
60
  finalizing_ui: bool = False
61
+ final_duration_seconds: float | None = None
62
+ final_duration_text: str | None = None
58
63
 
59
64
  def __post_init__(self) -> None:
60
65
  """Initialize renderer state after dataclass creation.
@@ -212,59 +217,76 @@ class RichStreamRenderer:
212
217
 
213
218
  # Handle different event types
214
219
  if kind == "status":
215
- # Status events
216
- status = ev.get("status")
217
- if status == "streaming_started":
218
- self.state.streaming_started_at = monotonic()
219
- return
220
-
220
+ self._handle_status_event(ev)
221
221
  elif kind == "content":
222
- # Content streaming events
223
- if content:
224
- self.state.buffer.append(content)
225
- self._ensure_live()
226
- return
227
-
222
+ self._handle_content_event(content)
228
223
  elif kind == "final_response":
229
- # Final response events
230
- if content:
231
- self.state.buffer.append(content)
232
- self.state.final_text = content
233
- self._ensure_live()
224
+ self._handle_final_response_event(content, metadata)
225
+ elif kind in {"agent_step", "agent_thinking_step"}:
226
+ self._handle_agent_step_event(ev)
227
+ else:
228
+ # Update live display for unhandled events
229
+ self._ensure_live()
234
230
 
235
- # In verbose mode, show the final result in a panel
236
- if self.verbose and content and content.strip():
237
- final_panel = create_final_panel(content, theme=self.cfg.theme)
238
- self.console.print(final_panel)
239
- self.state.printed_final_panel = True
240
- return
231
+ def _handle_status_event(self, ev: dict[str, Any]) -> None:
232
+ """Handle status events."""
233
+ status = ev.get("status")
234
+ if status == "streaming_started":
235
+ self.state.streaming_started_at = monotonic()
241
236
 
242
- elif kind in {"agent_step", "agent_thinking_step"}:
243
- # Agent step events
244
- # Note: Thinking gaps are primarily a visual aid. Keep minimal here.
245
-
246
- # Extract tool information
247
- (
248
- tool_name,
249
- tool_args,
250
- tool_out,
251
- tool_calls_info,
252
- ) = self.stream_processor.parse_tool_calls(ev)
253
-
254
- # Track tools and sub-agents
255
- self.stream_processor.track_tools_and_agents(
256
- tool_name, tool_calls_info, is_delegation_tool
257
- )
237
+ def _handle_content_event(self, content: str) -> None:
238
+ """Handle content streaming events."""
239
+ if content:
240
+ self.state.buffer.append(content)
241
+ self._ensure_live()
242
+
243
+ def _handle_final_response_event(
244
+ self, content: str, metadata: dict[str, Any]
245
+ ) -> None:
246
+ """Handle final response events."""
247
+ if content:
248
+ self.state.buffer.append(content)
249
+ self.state.final_text = content
258
250
 
259
- # Handle tool execution
260
- self._handle_agent_step(ev, tool_name, tool_args, tool_out, tool_calls_info)
251
+ meta_payload = metadata.get("metadata") or {}
252
+ self._update_final_duration(meta_payload.get("time"))
253
+
254
+ self._ensure_live()
255
+
256
+ # In verbose mode, show the final result in a panel
257
+ if self.verbose and content.strip():
258
+ final_panel = create_final_panel(
259
+ content,
260
+ title=self._final_panel_title(),
261
+ theme=self.cfg.theme,
262
+ )
263
+ self.console.print(final_panel)
264
+ self.state.printed_final_panel = True
265
+
266
+ def _handle_agent_step_event(self, ev: dict[str, Any]) -> None:
267
+ """Handle agent step events."""
268
+ # Extract tool information
269
+ (
270
+ tool_name,
271
+ tool_args,
272
+ tool_out,
273
+ tool_calls_info,
274
+ ) = self.stream_processor.parse_tool_calls(ev)
275
+
276
+ # Track tools and sub-agents
277
+ self.stream_processor.track_tools_and_agents(
278
+ tool_name, tool_calls_info, is_delegation_tool
279
+ )
280
+
281
+ # Handle tool execution
282
+ self._handle_agent_step(ev, tool_name, tool_args, tool_out, tool_calls_info)
261
283
 
262
284
  # Update live display
263
285
  self._ensure_live()
264
286
 
265
287
  def _finish_running_steps(self) -> None:
266
288
  """Mark any running steps as finished to avoid lingering spinners."""
267
- for st in list(self.steps.by_id.values()):
289
+ for st in self.steps.by_id.values():
268
290
  if not is_step_finished(st):
269
291
  st.finish(None)
270
292
 
@@ -289,14 +311,31 @@ class RichStreamRenderer:
289
311
  if self.verbose and not self.state.printed_final_panel:
290
312
  body = ("".join(self.state.buffer) or "").strip()
291
313
  if body:
292
- final_panel = create_final_panel(body, theme=self.cfg.theme)
314
+ final_panel = create_final_panel(
315
+ body,
316
+ title=self._final_panel_title(),
317
+ theme=self.cfg.theme,
318
+ )
293
319
  self.console.print(final_panel)
294
320
  self.state.printed_final_panel = True
295
321
 
296
- def on_complete(self, _stats: RunStats) -> None:
322
+ def on_complete(self, stats: RunStats) -> None:
297
323
  """Handle completion event."""
298
324
  self.state.finalizing_ui = True
299
325
 
326
+ if isinstance(stats, RunStats):
327
+ duration = None
328
+ try:
329
+ if stats.finished_at is not None and stats.started_at is not None:
330
+ duration = max(
331
+ 0.0, float(stats.finished_at) - float(stats.started_at)
332
+ )
333
+ except Exception:
334
+ duration = None
335
+
336
+ if duration is not None:
337
+ self._update_final_duration(duration, overwrite=True)
338
+
300
339
  # Mark any running steps as finished to avoid lingering spinners
301
340
  self._finish_running_steps()
302
341
 
@@ -394,15 +433,23 @@ class RichStreamRenderer:
394
433
  if not self.verbose:
395
434
  final_content = (self.state.final_text or "").strip()
396
435
  if final_content:
436
+ title = self._final_panel_title()
397
437
  return create_final_panel(
398
438
  final_content,
399
- title="Final Result",
439
+ title=title,
400
440
  theme=self.cfg.theme,
401
441
  )
402
442
  # Dynamic title with spinner + elapsed/hints
403
443
  title = self._format_enhanced_main_title()
404
444
  return create_main_panel(body, title, self.cfg.theme)
405
445
 
446
+ def _final_panel_title(self) -> str:
447
+ """Compose title for the final result panel including duration."""
448
+ title = "Final Result"
449
+ if self.state.final_duration_text:
450
+ title = f"{title} · {self.state.final_duration_text}"
451
+ return title
452
+
406
453
  def apply_verbosity(self, verbose: bool) -> None:
407
454
  """Update verbose behaviour at runtime."""
408
455
  if self.verbose == verbose:
@@ -507,27 +554,43 @@ class RichStreamRenderer:
507
554
  """Process additional tool calls to avoid duplicates."""
508
555
  for call_name, call_args, _ in tool_calls_info or []:
509
556
  if call_name and call_name != tool_name:
510
- self._ensure_tool_panel(call_name, call_args, task_id, context_id)
511
- if is_delegation_tool(call_name):
512
- st2 = self.steps.start_or_get(
513
- task_id=task_id,
514
- context_id=context_id,
515
- kind="delegate",
516
- name=call_name,
517
- args=call_args,
518
- )
519
- else:
520
- st2 = self.steps.start_or_get(
521
- task_id=task_id,
522
- context_id=context_id,
523
- kind="tool",
524
- name=call_name,
525
- args=call_args,
526
- )
527
- if self.stream_processor.server_elapsed_time is not None and st2:
528
- self._step_server_start_times[st2.step_id] = (
529
- self.stream_processor.server_elapsed_time
530
- )
557
+ self._process_single_tool_call(
558
+ call_name, call_args, task_id, context_id
559
+ )
560
+
561
+ def _process_single_tool_call(
562
+ self, call_name: str, call_args: Any, task_id: str, context_id: str
563
+ ) -> None:
564
+ """Process a single additional tool call."""
565
+ self._ensure_tool_panel(call_name, call_args, task_id, context_id)
566
+
567
+ st2 = self._create_step_for_tool_call(call_name, call_args, task_id, context_id)
568
+
569
+ if self.stream_processor.server_elapsed_time is not None and st2:
570
+ self._step_server_start_times[st2.step_id] = (
571
+ self.stream_processor.server_elapsed_time
572
+ )
573
+
574
+ def _create_step_for_tool_call(
575
+ self, call_name: str, call_args: Any, task_id: str, context_id: str
576
+ ) -> Any:
577
+ """Create appropriate step for tool call."""
578
+ if is_delegation_tool(call_name):
579
+ return self.steps.start_or_get(
580
+ task_id=task_id,
581
+ context_id=context_id,
582
+ kind="delegate",
583
+ name=call_name,
584
+ args=call_args,
585
+ )
586
+ else:
587
+ return self.steps.start_or_get(
588
+ task_id=task_id,
589
+ context_id=context_id,
590
+ kind="tool",
591
+ name=call_name,
592
+ args=call_args,
593
+ )
531
594
 
532
595
  def _detect_tool_completion(
533
596
  self, metadata: dict, content: str
@@ -941,19 +1004,19 @@ class RichStreamRenderer:
941
1004
  """Format step status with elapsed time or duration."""
942
1005
  if is_step_finished(step):
943
1006
  if step.duration_ms is None:
944
- return "[<1ms]"
1007
+ return LESS_THAN_1MS
945
1008
  elif step.duration_ms >= 1000:
946
1009
  return f"[{step.duration_ms / 1000:.2f}s]"
947
1010
  elif step.duration_ms > 0:
948
1011
  return f"[{step.duration_ms}ms]"
949
- return "[<1ms]"
1012
+ return LESS_THAN_1MS
950
1013
  else:
951
1014
  # Calculate elapsed time for running steps
952
1015
  elapsed = self._calculate_step_elapsed_time(step)
953
1016
  if elapsed >= 1:
954
1017
  return f"[{elapsed:.2f}s]"
955
1018
  ms = int(elapsed * 1000)
956
- return f"[{ms}ms]" if ms > 0 else "[<1ms]"
1019
+ return f"[{ms}ms]" if ms > 0 else LESS_THAN_1MS
957
1020
 
958
1021
  def _calculate_step_elapsed_time(self, step: Step) -> float:
959
1022
  """Calculate elapsed time for a running step."""
@@ -1024,6 +1087,29 @@ class RichStreamRenderer:
1024
1087
  and sid not in self.stream_processor.current_event_finished_panels
1025
1088
  )
1026
1089
 
1090
+ def _update_final_duration(
1091
+ self, duration: float | None, *, overwrite: bool = False
1092
+ ) -> None:
1093
+ """Store formatted duration for eventual final panels."""
1094
+ if duration is None:
1095
+ return
1096
+
1097
+ try:
1098
+ duration_val = max(0.0, float(duration))
1099
+ except Exception:
1100
+ return
1101
+
1102
+ existing = self.state.final_duration_seconds
1103
+
1104
+ if not overwrite and existing is not None:
1105
+ return
1106
+
1107
+ if overwrite and existing is not None:
1108
+ duration_val = max(existing, duration_val)
1109
+
1110
+ self.state.final_duration_seconds = duration_val
1111
+ self.state.final_duration_text = self._format_elapsed_time(duration_val)
1112
+
1027
1113
  def _calculate_elapsed_time(self, meta: dict[str, Any]) -> str:
1028
1114
  """Calculate elapsed time string for running tools."""
1029
1115
  server_elapsed = self.stream_processor.server_elapsed_time
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: glaip-sdk
3
- Version: 0.0.17
3
+ Version: 0.0.18
4
4
  Summary: Python SDK for GL AIP (GDP Labs AI Agent Package) - Simplified CLI Design
5
5
  License: MIT
6
6
  Author: Raymond Christopher
@@ -24,19 +24,19 @@ glaip_sdk/cli/resolution.py,sha256=jXUNpKKhs30n7Ke0uz1Hbny5DTo2_sxvchIhTbeBubE,2
24
24
  glaip_sdk/cli/rich_helpers.py,sha256=ByUOmK16IisoXWE7nEiI55BF1KWDrm6KCYAxqHu0XOU,825
25
25
  glaip_sdk/cli/slash/__init__.py,sha256=Vdv6Y8bu-pA8dxDlyP4XrhudBPivztUozhLAz9vaLig,682
26
26
  glaip_sdk/cli/slash/agent_session.py,sha256=-woZkqH70YUSaEHDF9XpxP-cbh36Jx7yuJW7aA3JszI,7078
27
- glaip_sdk/cli/slash/prompt.py,sha256=2CLAfdmX6yQedcNLnwZ4g6QFoV9TVv8il9OF8iaJwdc,7977
28
- glaip_sdk/cli/slash/session.py,sha256=JsTHZB8gPFFdcp_bhtw-nig37Z61OWK_okPMx47H86c,31484
27
+ glaip_sdk/cli/slash/prompt.py,sha256=Cfd6nL1T-F51WNuRCO09RxXfuJn0I1OyBi5dx3xKtaY,8407
28
+ glaip_sdk/cli/slash/session.py,sha256=WZKAwkio1DMK72r6myR8Ou7weIS5JsQutbtVMox7ctc,32515
29
29
  glaip_sdk/cli/update_notifier.py,sha256=nfQ-jRQKn-nZyt7EhxNfZq9Z7nBrYjZJKAgAtuHffnw,3410
30
30
  glaip_sdk/cli/utils.py,sha256=pgbV0f5rdjAHeZ-ULCntH7HUG6FdFB9kODv0a9puB40,35503
31
31
  glaip_sdk/cli/validators.py,sha256=USbBgY86AwuDHO-Q_g8g7hu-ot4NgITBsWjTWIl62ms,5569
32
32
  glaip_sdk/client/__init__.py,sha256=nYLXfBVTTWwKjP0e63iumPYO4k5FifwWaELQPaPIKIg,188
33
33
  glaip_sdk/client/_agent_payloads.py,sha256=sYlMzrfAdd8KC37qxokLy2uDd3aOhzQirnv7UYlvwYc,16385
34
- glaip_sdk/client/agents.py,sha256=GpDlxzhUQaMn9IUq_kCsbZo5TBfwWXFYs1bbpPLiSok,35492
34
+ glaip_sdk/client/agents.py,sha256=-ORfxYaoX6JkgGS9B7ZCQmr1o9H5KEr0GesbfeiSros,37594
35
35
  glaip_sdk/client/base.py,sha256=OPRlAWhZ77rUK0MRGA83-zW5NVhxJ1RgdfcfGOYr8rI,16267
36
- glaip_sdk/client/main.py,sha256=tfyyx9utReq7nXdtHKOCXQIUXXeZ1-D6u4OGTu6h8Es,8632
36
+ glaip_sdk/client/main.py,sha256=tELAA36rzthnNKTgwZ6lLPb3Au8Wh1mF8Kz-9N-YtCg,8652
37
37
  glaip_sdk/client/mcps.py,sha256=-O-I15qjbwfSA69mouHY6g5_qgPWC4rM98VJLpOkh1A,8975
38
38
  glaip_sdk/client/run_rendering.py,sha256=fXUj1FBw8n-nAzjI_zaG7-Ap_UXXe0z4tMdL7m2R7Ek,9213
39
- glaip_sdk/client/tools.py,sha256=n8DIiOOf1YU_j9JK3Bx2-rDnkpckPi0MI9Ok2s1kwa4,16634
39
+ glaip_sdk/client/tools.py,sha256=rWxfNO30sS468513IoE5PfEaqNq6HBwmcHVh4FzhvYQ,17532
40
40
  glaip_sdk/client/validators.py,sha256=NtPsWjQLjj25LiUnmR-WuS8lL5p4MVRaYT9UVRmj9bo,8809
41
41
  glaip_sdk/config/constants.py,sha256=B9CSlYG8LYjQuo_vNpqy-eSks3ej37FMcvJMy6d_F4U,888
42
42
  glaip_sdk/exceptions.py,sha256=ILquxC4QGPFR9eY6RpeXzkQsblfsvZMGFqz38ZjeW3E,2345
@@ -45,16 +45,16 @@ glaip_sdk/payload_schemas/__init__.py,sha256=fJamlkpS3IfS9xyKAQaUbnalvrtG5Ied69O
45
45
  glaip_sdk/payload_schemas/agent.py,sha256=nlizuv2w4SVzmMJSE90rE6Ll0Hfpcr5hvPsW_NtXCV0,3204
46
46
  glaip_sdk/rich_components.py,sha256=veaps1hrSkC3nSVunAevvynSux8Cg3yFEDmbJk66p7w,1267
47
47
  glaip_sdk/utils/__init__.py,sha256=fmVGcUFa7G0CCfSMSqfNU2BqFl36G1gOFyDfTvtJfVw,926
48
- glaip_sdk/utils/agent_config.py,sha256=b7_J5DELyk0b_XEoi7tsxbS3wqzAKbMa-3_C-65pPIY,6791
48
+ glaip_sdk/utils/agent_config.py,sha256=p3uK5qC0M5uQv9uY7-U8ej11Vh81fwKAPSsYcRoNdlk,7342
49
49
  glaip_sdk/utils/client_utils.py,sha256=x27kHQNOxvyVN5GLUiymi0eHzkXRKw-x3s0q0VkMvY4,13938
50
50
  glaip_sdk/utils/display.py,sha256=94s9lYF_8ra8jpeqOkbVrUm8oidtCE6OtucyxLQPKmU,3105
51
51
  glaip_sdk/utils/general.py,sha256=V5hJrIpYDvDsldU_nChHpuvV2AwhFLUI7Qvcaihq_8A,2270
52
52
  glaip_sdk/utils/import_export.py,sha256=jEhl5U6hWWMR1wo5AXpV-_jN_56DcWcemOa2UaFHapk,5217
53
53
  glaip_sdk/utils/rendering/__init__.py,sha256=vXjwk5rPhhfPyD8S0DnV4GFFEtPJp4HCCg1Um9SXfs0,70
54
- glaip_sdk/utils/rendering/formatting.py,sha256=I8nN4H3DxTOYIExn6gozcxyAn_GO1lSztbcrSCkcscg,7351
54
+ glaip_sdk/utils/rendering/formatting.py,sha256=mS4xvbNy1NSH4nXm8mKj03jEXMNinxfbtVJGYf3sXlk,7770
55
55
  glaip_sdk/utils/rendering/models.py,sha256=AM9JbToyA3zrAzXQYjh6oxjBkgZDfWEbs5MmNKODnOY,2259
56
56
  glaip_sdk/utils/rendering/renderer/__init__.py,sha256=EXwVBmGkSYcype4ocAXo69Z1kXu0gpNXmhH5LW0_B7A,2939
57
- glaip_sdk/utils/rendering/renderer/base.py,sha256=HGljrxMcDq4QCsWcwR45qZYjKWZprBunnZJYYea8FTQ,42764
57
+ glaip_sdk/utils/rendering/renderer/base.py,sha256=Tk-N0Fpi4kyuJYb-YaYEAytpho2vcWBKdDmzqIK_Pto,45543
58
58
  glaip_sdk/utils/rendering/renderer/config.py,sha256=-P35z9JO_1ypJXAqxJ1ybHraH4i-I1LPopeW3Lh7ACE,785
59
59
  glaip_sdk/utils/rendering/renderer/console.py,sha256=4cLOw4Q1fkHkApuj6dWW8eYpeYdcT0t2SO5MbVt5UTc,1844
60
60
  glaip_sdk/utils/rendering/renderer/debug.py,sha256=FEYxAu4ZB0CjrJKevqQ2TKDgElA2cf6GqZXCNm12sNQ,3721
@@ -67,7 +67,7 @@ glaip_sdk/utils/rich_utils.py,sha256=-Ij-1bIJvnVAi6DrfftchIlMcvOTjVmSE0Qqax0EY_s
67
67
  glaip_sdk/utils/run_renderer.py,sha256=d_VMI6LbvHPUUeRmGqh5wK_lHqDEIAcym2iqpbtDad0,1365
68
68
  glaip_sdk/utils/serialization.py,sha256=AFbucakFaCtQDfcgsm2gHZ1iZDA8OaJSZUsS6FhWFR0,12820
69
69
  glaip_sdk/utils/validation.py,sha256=QNORcdyvuliEs4EH2_mkDgmoyT9utgl7YNhaf45SEf8,6992
70
- glaip_sdk-0.0.17.dist-info/METADATA,sha256=z1GiwKAgE5eXkCbBfUXrQuMZBUQ14XWuOZhgj5ugyAw,5164
71
- glaip_sdk-0.0.17.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
72
- glaip_sdk-0.0.17.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
73
- glaip_sdk-0.0.17.dist-info/RECORD,,
70
+ glaip_sdk-0.0.18.dist-info/METADATA,sha256=IaSz5FAxBzrHDKtbgjyg6h4vyClUS7C3M4ovlax0rv4,5164
71
+ glaip_sdk-0.0.18.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
72
+ glaip_sdk-0.0.18.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
73
+ glaip_sdk-0.0.18.dist-info/RECORD,,