glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +271 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -29,6 +29,28 @@ def is_uuid(value: str) -> bool:
29
29
  return False
30
30
 
31
31
 
32
+ def _extract_id_from_item(item: Any, *, skip_missing: bool = False) -> str | None:
33
+ """Extract ID from a single item.
34
+
35
+ Args:
36
+ item: Item that may be a string, object with .id, or dict with "id" key.
37
+ skip_missing: If True, return None for items without IDs. If False, convert to string.
38
+
39
+ Returns:
40
+ Extracted ID as string, or None if skip_missing=True and no ID found.
41
+ """
42
+ if isinstance(item, str):
43
+ return item
44
+ if hasattr(item, "id"):
45
+ return str(item.id)
46
+ if isinstance(item, dict) and "id" in item:
47
+ return str(item["id"])
48
+ if skip_missing:
49
+ return None
50
+ # Fallback: convert to string
51
+ return str(item)
52
+
53
+
32
54
  def extract_ids(items: list[str | Any] | None) -> list[str]:
33
55
  """Extract IDs from a list of objects or strings.
34
56
 
@@ -49,19 +71,9 @@ def extract_ids(items: list[str | Any] | None) -> list[str]:
49
71
  if not items:
50
72
  return []
51
73
 
52
- ids = []
53
- for item in items:
54
- if isinstance(item, str):
55
- ids.append(item)
56
- elif hasattr(item, "id"):
57
- ids.append(str(item.id))
58
- elif isinstance(item, dict) and "id" in item:
59
- ids.append(str(item["id"]))
60
- else:
61
- # Fallback: convert to string
62
- ids.append(str(item))
63
-
64
- return ids
74
+ # Extract IDs from all items, converting non-ID items to strings
75
+ extracted_ids = [_extract_id_from_item(item, skip_missing=False) for item in items]
76
+ return [id_val for id_val in extracted_ids if id_val is not None]
65
77
 
66
78
 
67
79
  def extract_names(items: list[str | Any] | None) -> list[str]:
@@ -95,9 +107,7 @@ def extract_names(items: list[str | Any] | None) -> list[str]:
95
107
  return names
96
108
 
97
109
 
98
- def find_by_name(
99
- items: list[Any], name: str, case_sensitive: bool = False
100
- ) -> list[Any]:
110
+ def find_by_name(items: list[Any], name: str, case_sensitive: bool = False) -> list[Any]:
101
111
  """Filter items by name with optional case sensitivity.
102
112
 
103
113
  This is a common pattern used across different clients for client-side
@@ -165,16 +175,12 @@ def validate_name_format(name: str, resource_type: str = "resource") -> str:
165
175
 
166
176
  # Check for valid characters (alphanumeric, hyphens, underscores)
167
177
  if not re.match(r"^[a-zA-Z0-9_-]+$", cleaned_name):
168
- raise ValueError(
169
- f"{display_type} name can only contain letters, numbers, hyphens, and underscores"
170
- )
178
+ raise ValueError(f"{display_type} name can only contain letters, numbers, hyphens, and underscores")
171
179
 
172
180
  return cleaned_name
173
181
 
174
182
 
175
- def validate_name_uniqueness(
176
- name: str, existing_names: list[str], resource_type: str = "resource"
177
- ) -> None:
183
+ def validate_name_uniqueness(name: str, existing_names: list[str], resource_type: str = "resource") -> None:
178
184
  """Validate that a resource name is unique.
179
185
 
180
186
  Args:
@@ -186,7 +192,4 @@ def validate_name_uniqueness(
186
192
  ValueError: If name is not unique
187
193
  """
188
194
  if name.lower() in [existing.lower() for existing in existing_names]:
189
- raise ValueError(
190
- f"A {resource_type.lower()} named '{name}' already exists. "
191
- "Please choose a unique name."
192
- )
195
+ raise ValueError(f"A {resource_type.lower()} named '{name}' already exists. Please choose a unique name.")
@@ -0,0 +1,425 @@
1
+ """Runtime configuration helpers for agent execution.
2
+
3
+ Provides utilities for normalizing runtime_config keys from various input types
4
+ (SDK objects, UUIDs, names) to stable platform IDs.
5
+
6
+ Authors:
7
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from glaip_sdk.utils.resource_refs import is_uuid
15
+ from gllm_core.utils import LoggerManager
16
+
17
+ if TYPE_CHECKING:
18
+ from glaip_sdk.agents import Agent
19
+ from glaip_sdk.mcps import MCP
20
+ from glaip_sdk.registry import AgentRegistry, MCPRegistry, ToolRegistry
21
+ from glaip_sdk.tools import Tool
22
+
23
+ # Type alias for config key inputs (only used in type hints)
24
+ ConfigKeyInput = str | Agent | Tool | MCP | type[Agent] | type[Tool] | type[MCP]
25
+
26
+ # Type alias for registry types
27
+ Registry = ToolRegistry | MCPRegistry | AgentRegistry
28
+
29
+ # Type alias for runtime config dict shape after normalization
30
+ # Top-level keys include:
31
+ # - "tool_configs", "mcp_configs", "agent_config"
32
+ # - Agent IDs for agent-specific overrides
33
+ # Values are nested dictionaries whose contents depend on the section.
34
+ RuntimeConfigDict = dict[str, dict[str, object]]
35
+
36
+ logger = LoggerManager().get_logger(__name__)
37
+
38
+ # Config fields that need key normalization (maps field name to registry type)
39
+ _NORMALIZABLE_FIELDS = {
40
+ "tool_configs": "tool",
41
+ "mcp_configs": "mcp",
42
+ }
43
+
44
+ # Config fields that are preserved as-is (no normalization needed)
45
+ _PASSTHROUGH_FIELDS = {"agent_config"}
46
+
47
+
48
+ def _extract_id_from_key(key: ConfigKeyInput) -> str | None:
49
+ """Extract ID directly from key if available.
50
+
51
+ Args:
52
+ key: The config key to extract ID from.
53
+
54
+ Returns:
55
+ The ID string if available, None otherwise.
56
+ """
57
+ if isinstance(key, str) and is_uuid(key):
58
+ return key
59
+ if hasattr(key, "id") and key.id is not None:
60
+ return key.id
61
+ return None
62
+
63
+
64
+ def _resolve_via_registry(key: ConfigKeyInput, registry: Registry | None) -> str | None:
65
+ """Attempt to resolve key via registry.
66
+
67
+ Args:
68
+ key: The config key to resolve.
69
+ registry: Registry to use for resolution.
70
+
71
+ Returns:
72
+ The resolved ID string if successful, None otherwise.
73
+ """
74
+ if registry is None:
75
+ return None
76
+
77
+ try:
78
+ return registry.resolve(key).id
79
+ except (KeyError, ValueError, AttributeError) as exc:
80
+ logger.debug("Failed to resolve key via registry: %r", key, exc_info=exc)
81
+ return None
82
+ except Exception as exc: # pragma: no cover - unexpected failures
83
+ logger.warning(
84
+ "Unexpected error resolving key via registry for %r: %s",
85
+ key,
86
+ exc,
87
+ exc_info=True,
88
+ )
89
+ return None
90
+
91
+
92
+ def _resolve_config_key(
93
+ key: ConfigKeyInput,
94
+ registry: Registry | None,
95
+ *,
96
+ missing_registry_message: str,
97
+ unresolved_message: str,
98
+ ) -> str:
99
+ """Resolve a config key using a registry when needed.
100
+
101
+ For non-ID keys this always requires a registry; callers customize the
102
+ error messages for missing registries vs unresolved keys.
103
+ """
104
+ # Allow direct UUID / object.id without needing a registry
105
+ if (extracted_id := _extract_id_from_key(key)) is not None:
106
+ return extracted_id
107
+
108
+ # For non-ID keys we always require a registry
109
+ if registry is None:
110
+ raise ValueError(missing_registry_message.format(key=key))
111
+
112
+ if (resolved_id := _resolve_via_registry(key, registry)) is not None:
113
+ return resolved_id
114
+
115
+ raise ValueError(unresolved_message.format(key=key))
116
+
117
+
118
+ def _normalize_section_keys(
119
+ config_section: dict[ConfigKeyInput, dict[str, object]],
120
+ registry: Registry | None,
121
+ ) -> dict[str, dict[str, object]]:
122
+ """Normalize keys in a single config section (e.g. tool_configs).
123
+
124
+ Converts ConfigKeyInput keys (SDK objects, names, classes) to stable IDs
125
+ using the provided registry.
126
+
127
+ Example:
128
+ Input: {ToolClass: {"param": "value"}, "tool-name": {"x": 1}}
129
+ Output: {"uuid-1": {"param": "value"}, "uuid-2": {"x": 1}}
130
+
131
+ Args:
132
+ config_section: The config section dict to normalize.
133
+ registry: Registry to use for resolving keys.
134
+
135
+ Returns:
136
+ Normalized config section with all keys converted to IDs.
137
+ """
138
+ normalized: dict[str, dict[str, object]] = {}
139
+ for key, value in config_section.items():
140
+ resolved_id = _resolve_config_key(
141
+ key,
142
+ registry,
143
+ missing_registry_message="Unable to resolve runtime_config key via registry: {key!r}",
144
+ unresolved_message="Unable to resolve runtime_config key via registry: {key!r}",
145
+ )
146
+ normalized[resolved_id] = value
147
+ return normalized
148
+
149
+
150
+ def _is_agent_specific_key(key: object) -> bool:
151
+ """Check if a key represents an agent-specific override.
152
+
153
+ Agent-specific keys are:
154
+ - Agent instances (from glaip_sdk.agents.Agent)
155
+ - UUID strings (agent IDs)
156
+ - Non-reserved string names (resolved via agent_registry)
157
+
158
+ NOT agent-specific:
159
+ - Known config field names (tool_configs, mcp_configs, agent_config)
160
+ - Tool or MCP objects (these are only valid inside *_configs sections)
161
+
162
+ Args:
163
+ key: The key to check.
164
+
165
+ Returns:
166
+ True if the key could be an agent-specific override.
167
+ """
168
+ from glaip_sdk.agents import Agent # noqa: PLC0415
169
+
170
+ # Agent instance
171
+ if isinstance(key, Agent):
172
+ return True
173
+
174
+ # Non-string keys that are not Agent instances are not agent-specific
175
+ if not isinstance(key, str):
176
+ return False
177
+
178
+ # Known config field names are not agent-specific
179
+ if key in _NORMALIZABLE_FIELDS or key in _PASSTHROUGH_FIELDS:
180
+ return False
181
+
182
+ # Any other string key is treated as an agent reference (ID or name)
183
+ return True
184
+
185
+
186
+ def _normalize_standard_config(
187
+ config: dict[str, object],
188
+ tool_registry: ToolRegistry | None,
189
+ mcp_registry: MCPRegistry | None,
190
+ context: str = "",
191
+ ) -> dict[str, object]:
192
+ """Normalize a standard config dict with tool_configs, mcp_configs, agent_config sections.
193
+
194
+ Used for both global runtime_config and agent-specific nested configs.
195
+ Delegates key normalization to _normalize_section_keys for each section.
196
+
197
+ Example:
198
+ Input: {"tool_configs": {ToolClass: {...}}, "agent_config": {...}}
199
+ Output: {"tool_configs": {"tool-uuid": {...}}, "agent_config": {...}}
200
+
201
+ Args:
202
+ config: The config dict to normalize.
203
+ tool_registry: Registry for resolving tool keys.
204
+ mcp_registry: Registry for resolving MCP keys.
205
+ context: Context string for warning messages.
206
+
207
+ Returns:
208
+ Normalized config dict.
209
+ """
210
+ registries: dict[str, Registry | None] = {
211
+ "tool": tool_registry,
212
+ "mcp": mcp_registry,
213
+ }
214
+
215
+ result: dict[str, object] = {}
216
+
217
+ for field, value in config.items():
218
+ if field in _NORMALIZABLE_FIELDS and isinstance(value, dict):
219
+ registry_type = _NORMALIZABLE_FIELDS[field]
220
+ result[field] = _normalize_section_keys(value, registries.get(registry_type))
221
+ elif field in _PASSTHROUGH_FIELDS:
222
+ result[field] = value
223
+ else:
224
+ logger.warning("Unknown field '%s' in %s, ignoring", field, context or "config")
225
+
226
+ return result
227
+
228
+
229
+ def normalize_runtime_config_keys(
230
+ runtime_config: RuntimeConfigDict | None,
231
+ tool_registry: ToolRegistry | None,
232
+ mcp_registry: MCPRegistry | None,
233
+ agent_registry: AgentRegistry | None,
234
+ ) -> RuntimeConfigDict | None:
235
+ """Normalize runtime_config keys from various input types to stable IDs.
236
+
237
+ Handles both global configs and agent-specific overrides:
238
+ - Global: tool_configs, mcp_configs, agent_config
239
+ - Agent-specific: keyed by Agent object, agent UUID, or agent name string
240
+
241
+ Example:
242
+ Input:
243
+ {
244
+ "tool_configs": {ToolClass: {"param": "val"}},
245
+ "agent_config": {"planning": True},
246
+ AgentClass: {"mcp_configs": {MCPClass: {...}}}
247
+ }
248
+ Output:
249
+ {
250
+ "tool_configs": {"tool-uuid": {"param": "val"}},
251
+ "agent_config": {"planning": True},
252
+ "agent-uuid": {"mcp_configs": {"mcp-uuid": {...}}}
253
+ }
254
+
255
+ Converts keys from:
256
+ - SDK objects → their .id attribute
257
+ - UUID strings → passed through
258
+ - Names → resolved via appropriate registry
259
+
260
+ Args:
261
+ runtime_config: The runtime configuration dict to normalize.
262
+ tool_registry: Registry for resolving tool keys.
263
+ mcp_registry: Registry for resolving MCP keys.
264
+ agent_registry: Registry for resolving agent keys.
265
+
266
+ Returns:
267
+ Normalized runtime_config with all keys converted to IDs, or None if input is None.
268
+ """
269
+ if runtime_config is None:
270
+ return None
271
+
272
+ if not runtime_config:
273
+ return {}
274
+
275
+ registries: dict[str, Registry | None] = {
276
+ "tool": tool_registry,
277
+ "mcp": mcp_registry,
278
+ }
279
+
280
+ result: dict[str, object] = {}
281
+
282
+ for field, value in runtime_config.items():
283
+ if field in _NORMALIZABLE_FIELDS and isinstance(value, dict):
284
+ registry_type = _NORMALIZABLE_FIELDS[field]
285
+ result[field] = _normalize_section_keys(value, registries.get(registry_type))
286
+ elif field in _PASSTHROUGH_FIELDS:
287
+ result[field] = value
288
+ elif _is_agent_specific_key(field) and isinstance(value, dict):
289
+ agent_id = _resolve_config_key(
290
+ field,
291
+ agent_registry,
292
+ missing_registry_message=(
293
+ "Agent-specific runtime_config provided but no agent_registry is available to resolve key: {key!r}"
294
+ ),
295
+ unresolved_message="Unable to resolve agent-specific runtime_config key: {key!r}",
296
+ )
297
+ result[agent_id] = _normalize_standard_config(
298
+ value,
299
+ tool_registry,
300
+ mcp_registry,
301
+ context=f"agent '{agent_id}'",
302
+ )
303
+ else:
304
+ logger.warning("Unknown field '%s' in runtime_config, ignoring", field)
305
+
306
+ return result
307
+
308
+
309
+ # =============================================================================
310
+ # LOCAL MODE UTILITIES
311
+ # =============================================================================
312
+ # The functions below are for local execution mode where resources are NOT
313
+ # deployed and have no UUIDs. They resolve keys to names (not IDs).
314
+ # =============================================================================
315
+
316
+
317
+ def _get_name_from_class(cls: type) -> str:
318
+ """Extract name from a class, handling Pydantic models and @property descriptors.
319
+
320
+ Args:
321
+ cls: The class to extract name from.
322
+
323
+ Returns:
324
+ The resolved name string.
325
+ """
326
+ # Try class-level name attribute first, but guard against @property descriptors
327
+ # When a class has @property name, getattr returns the property object, not a string
328
+ class_name = getattr(cls, "name", None)
329
+ if isinstance(class_name, str) and class_name:
330
+ return class_name
331
+
332
+ # For Pydantic models, check model_fields for default value
333
+ model_fields = getattr(cls, "model_fields", None)
334
+ if model_fields and "name" in model_fields:
335
+ field_info = model_fields["name"]
336
+ default = getattr(field_info, "default", None)
337
+ if default and isinstance(default, str):
338
+ return default
339
+
340
+ # Fallback to class __name__
341
+ return cls.__name__
342
+
343
+
344
+ def get_name_from_key(key: object) -> str | None:
345
+ """Resolve config key to name for local mode (no registry needed).
346
+
347
+ Supports instances, classes, and string names. UUID strings are not
348
+ supported in local mode and return None with a warning.
349
+
350
+ Args:
351
+ key: Tool, MCP, or Agent instance/class/string.
352
+
353
+ Returns:
354
+ The resolved name string, or None if UUID (not applicable locally).
355
+
356
+ Raises:
357
+ ValueError: If the key cannot be resolved to a valid name.
358
+ """
359
+ # Class type (not instance) - must check BEFORE hasattr("name")
360
+ # because classes with @property name will have hasattr return True
361
+ # but getattr returns the property descriptor, not a string
362
+ if isinstance(key, type):
363
+ return _get_name_from_class(key)
364
+
365
+ # String key - check early to avoid attribute access
366
+ if isinstance(key, str):
367
+ if is_uuid(key):
368
+ logger.warning("UUID '%s' not supported in local mode, skipping", key)
369
+ return None
370
+ return key
371
+
372
+ # Instance with name attribute
373
+ if hasattr(key, "name"):
374
+ name = getattr(key, "name", None)
375
+ # Guard against @property that returns non-string (e.g., descriptor)
376
+ if isinstance(name, str) and name:
377
+ return name
378
+
379
+ raise ValueError(f"Unable to resolve config key: {key!r}")
380
+
381
+
382
+ def normalize_local_config_keys(config: dict[object, object]) -> dict[str, object]:
383
+ """Normalize all keys in a config dict to names for local mode.
384
+
385
+ Converts instance/class/string keys to string names without using
386
+ registry. UUID keys are skipped with a warning.
387
+
388
+ Args:
389
+ config: Dict with instance/class/string keys and any values.
390
+
391
+ Returns:
392
+ Dict with string name keys only. UUID keys are omitted.
393
+ """
394
+ if not config:
395
+ return {}
396
+
397
+ result: dict[str, object] = {}
398
+ for key, value in config.items():
399
+ name = get_name_from_key(key)
400
+ if name is not None:
401
+ result[name] = value
402
+ return result
403
+
404
+
405
+ def merge_configs(*configs: dict | None) -> dict:
406
+ """Merge multiple config dicts with priority ordering.
407
+
408
+ Later configs override earlier ones for the same key. None configs
409
+ are skipped gracefully.
410
+
411
+ Args:
412
+ *configs: Config dicts in priority order (lowest priority first).
413
+
414
+ Returns:
415
+ Merged config dict with later values overriding earlier ones.
416
+
417
+ Example:
418
+ >>> merge_configs({"a": 1}, {"a": 2, "b": 3})
419
+ {"a": 2, "b": 3}
420
+ """
421
+ result: dict = {}
422
+ for config in configs:
423
+ if config:
424
+ result.update(config)
425
+ return result
@@ -67,20 +67,12 @@ def read_yaml(file_path: Path) -> dict[str, Any]:
67
67
  data = yaml.safe_load(f)
68
68
 
69
69
  # Handle instruction_lines array format for user-friendly YAML
70
- if (
71
- isinstance(data, dict)
72
- and "instruction_lines" in data
73
- and isinstance(data["instruction_lines"], list)
74
- ):
70
+ if isinstance(data, dict) and "instruction_lines" in data and isinstance(data["instruction_lines"], list):
75
71
  data["instruction"] = "\n\n".join(data["instruction_lines"])
76
72
  del data["instruction_lines"]
77
73
 
78
74
  # Handle instruction as list from YAML export (convert back to string)
79
- if (
80
- isinstance(data, dict)
81
- and "instruction" in data
82
- and isinstance(data["instruction"], list)
83
- ):
75
+ if isinstance(data, dict) and "instruction" in data and isinstance(data["instruction"], list):
84
76
  data["instruction"] = "\n\n".join(data["instruction"])
85
77
 
86
78
  return data
@@ -96,11 +88,20 @@ def write_yaml(file_path: Path, data: dict[str, Any]) -> None:
96
88
 
97
89
  # Custom YAML dumper for user-friendly instruction formatting
98
90
  class LiteralString(str):
91
+ """String subclass for YAML literal block scalar formatting."""
92
+
99
93
  pass
100
94
 
101
- def literal_string_representer(
102
- dumper: yaml.Dumper, data: "LiteralString"
103
- ) -> yaml.nodes.Node:
95
+ def literal_string_representer(dumper: yaml.Dumper, data: "LiteralString") -> yaml.nodes.Node:
96
+ """YAML representer for LiteralString to use literal block scalar style.
97
+
98
+ Args:
99
+ dumper: YAML dumper instance.
100
+ data: LiteralString instance to represent.
101
+
102
+ Returns:
103
+ YAML node with literal block scalar style for multiline strings.
104
+ """
104
105
  # Use literal block scalar (|) for multiline strings to preserve formatting
105
106
  if "\n" in data:
106
107
  return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
@@ -115,9 +116,7 @@ def write_yaml(file_path: Path, data: dict[str, Any]) -> None:
115
116
  data["instruction"] = LiteralString(data["instruction"])
116
117
 
117
118
  with open(file_path, "w", encoding="utf-8") as f:
118
- yaml.dump(
119
- data, f, default_flow_style=False, allow_unicode=True, sort_keys=False
120
- )
119
+ yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
121
120
 
122
121
 
123
122
  def load_resource_from_file(file_path: Path) -> dict[str, Any]:
@@ -137,15 +136,10 @@ def load_resource_from_file(file_path: Path) -> dict[str, Any]:
137
136
  elif file_path.suffix.lower() == ".json":
138
137
  return read_json(file_path)
139
138
  else:
140
- raise ValueError(
141
- f"Unsupported file format: {file_path.suffix}. "
142
- f"Only JSON and YAML files are supported."
143
- )
139
+ raise ValueError(f"Unsupported file format: {file_path.suffix}. Only JSON and YAML files are supported.")
144
140
 
145
141
 
146
- def write_resource_export(
147
- file_path: Path, data: dict[str, Any], format: str = "json"
148
- ) -> None:
142
+ def write_resource_export(file_path: Path, data: dict[str, Any], format: str = "json") -> None:
149
143
  """Write resource export data to file.
150
144
 
151
145
  Args:
@@ -190,13 +184,8 @@ def collect_attributes_for_export(resource: Any) -> dict[str, Any]:
190
184
  data.
191
185
  """
192
186
  mapping = _coerce_resource_to_mapping(resource)
193
- if (
194
- mapping is None
195
- ): # pragma: no cover - defensive fallback when attribute introspection fails
196
- items = (
197
- (name, _safe_getattr(resource, name))
198
- for name in _iter_public_attribute_names(resource)
199
- )
187
+ if mapping is None: # pragma: no cover - defensive fallback when attribute introspection fails
188
+ items = ((name, _safe_getattr(resource, name)) for name in _iter_public_attribute_names(resource))
200
189
  else:
201
190
  items = mapping.items()
202
191
 
@@ -249,9 +238,7 @@ def _coerce_resource_to_mapping(resource: Any) -> dict[str, Any] | None:
249
238
  try:
250
239
  if hasattr(resource, "__dict__"):
251
240
  return dict(resource.__dict__)
252
- except (
253
- Exception
254
- ): # pragma: no cover - pathological objects can still defeat coercion
241
+ except Exception: # pragma: no cover - pathological objects can still defeat coercion
255
242
  return None
256
243
 
257
244
  return None
@@ -263,6 +250,11 @@ def _iter_public_attribute_names(resource: Any) -> Iterable[str]:
263
250
  names: list[str] = []
264
251
 
265
252
  def _collect(candidates: Iterable[str] | None) -> None:
253
+ """Collect unique candidate attribute names.
254
+
255
+ Args:
256
+ candidates: Iterable of candidate attribute names.
257
+ """
266
258
  for candidate in candidates or ():
267
259
  if candidate not in seen:
268
260
  seen.add(candidate)
@@ -284,9 +276,7 @@ def _iter_public_attribute_names(resource: Any) -> Iterable[str]:
284
276
  return iter(names)
285
277
 
286
278
 
287
- def _collect_from_dict(
288
- resource: Any, collect_func: Callable[[Iterable[str]], None]
289
- ) -> None:
279
+ def _collect_from_dict(resource: Any, collect_func: Callable[[Iterable[str]], None]) -> None:
290
280
  """Safely collect attribute names from __dict__."""
291
281
  try:
292
282
  if hasattr(resource, "__dict__"):
@@ -297,18 +287,14 @@ def _collect_from_dict(
297
287
  pass
298
288
 
299
289
 
300
- def _collect_from_annotations(
301
- resource: Any, collect_func: Callable[[Iterable[str]], None]
302
- ) -> None:
290
+ def _collect_from_annotations(resource: Any, collect_func: Callable[[Iterable[str]], None]) -> None:
303
291
  """Safely collect attribute names from __annotations__."""
304
292
  annotations = getattr(resource, "__annotations__", {})
305
293
  if annotations:
306
294
  collect_func(annotations.keys())
307
295
 
308
296
 
309
- def _collect_from_dir(
310
- resource: Any, collect_func: Callable[[Iterable[str]], None]
311
- ) -> None:
297
+ def _collect_from_dir(resource: Any, collect_func: Callable[[Iterable[str]], None]) -> None:
312
298
  """Safely collect attribute names from dir()."""
313
299
  try:
314
300
  collect_func(name for name in dir(resource) if not name.startswith("__"))
@@ -317,6 +303,7 @@ def _collect_from_dir(
317
303
 
318
304
 
319
305
  def _safe_getattr(resource: Any, name: str) -> Any:
306
+ """Return getattr(resource, name) but swallow any exception and return None."""
320
307
  try:
321
308
  return getattr(resource, name)
322
309
  except Exception:
@@ -324,6 +311,7 @@ def _safe_getattr(resource: Any, name: str) -> Any:
324
311
 
325
312
 
326
313
  def _should_include_attribute(key: str, value: Any) -> bool:
314
+ """Return True when an attribute should be serialized."""
327
315
  if key in _EXCLUDED_ATTRS or key in _EXCLUDED_NAMES:
328
316
  return False
329
317
  if key.startswith("_"):
@@ -387,9 +375,7 @@ def build_mcp_export_payload(
387
375
  ImportError: If required modules (auth helpers) are not available
388
376
  """
389
377
  auth_module = importlib.import_module("glaip_sdk.cli.auth")
390
- prepare_authentication_export = getattr(
391
- auth_module, "prepare_authentication_export"
392
- )
378
+ prepare_authentication_export = auth_module.prepare_authentication_export
393
379
 
394
380
  # Start with model dump (excludes None values automatically)
395
381
  payload = mcp.model_dump(exclude_none=True)
@@ -435,4 +421,4 @@ def validate_json_string(json_str: str) -> dict[str, Any]:
435
421
  try:
436
422
  return json.loads(json_str)
437
423
  except json.JSONDecodeError as e:
438
- raise ValueError(f"Invalid JSON: {e}")
424
+ raise ValueError(f"Invalid JSON: {e}") from e