glaip-sdk 0.6.5b9__py3-none-any.whl → 0.6.8b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
glaip_sdk/agents/base.py CHANGED
@@ -46,6 +46,8 @@ import inspect
46
46
  import logging
47
47
  import warnings
48
48
  from collections.abc import AsyncGenerator
49
+
50
+
49
51
  from pathlib import Path
50
52
  from typing import TYPE_CHECKING, Any
51
53
 
@@ -869,7 +871,7 @@ class Agent:
869
871
  ValueError: If the agent hasn't been deployed yet.
870
872
  RuntimeError: If client is not available.
871
873
  """
872
- if not self.id:
874
+ if not self.id: # pragma: no cover - defensive: called only when self.id is truthy
873
875
  raise ValueError(_AGENT_NOT_DEPLOYED_MSG)
874
876
  if not self._client:
875
877
  raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
@@ -893,6 +895,48 @@ class Agent:
893
895
  call_kwargs.update(kwargs)
894
896
  return agent_client, call_kwargs
895
897
 
898
+ def _get_local_runner_or_raise(self) -> Any:
899
+ """Get the local runner if available, otherwise raise ValueError.
900
+
901
+ Returns:
902
+ The default local runner instance.
903
+
904
+ Raises:
905
+ ValueError: If local runtime is not available.
906
+ """
907
+ if check_local_runtime_available():
908
+ return get_default_runner()
909
+ raise ValueError(f"{_AGENT_NOT_DEPLOYED_MSG}\n\n{get_local_runtime_missing_message()}")
910
+
911
+ def _prepare_local_runner_kwargs(
912
+ self,
913
+ message: str,
914
+ verbose: bool,
915
+ runtime_config: dict[str, Any] | None,
916
+ chat_history: list[dict[str, str]] | None,
917
+ **kwargs: Any,
918
+ ) -> dict[str, Any]:
919
+ """Prepare kwargs for local runner execution.
920
+
921
+ Args:
922
+ message: The message to send to the agent.
923
+ verbose: If True, print streaming output to console.
924
+ runtime_config: Optional runtime configuration.
925
+ chat_history: Optional list of prior conversation messages.
926
+ **kwargs: Additional arguments.
927
+
928
+ Returns:
929
+ Dictionary of prepared kwargs for runner.run() or runner.arun().
930
+ """
931
+ return {
932
+ "agent": self,
933
+ "message": message,
934
+ "verbose": verbose,
935
+ "runtime_config": runtime_config,
936
+ "chat_history": chat_history,
937
+ **kwargs,
938
+ }
939
+
896
940
  def run(
897
941
  self,
898
942
  message: str,
@@ -943,32 +987,29 @@ class Agent:
943
987
  return agent_client.run_agent(**call_kwargs)
944
988
 
945
989
  # Local execution path (agent is not deployed)
946
- if check_local_runtime_available():
947
- runner = get_default_runner()
948
- return runner.run(
949
- agent=self,
950
- message=message,
951
- verbose=verbose,
952
- runtime_config=runtime_config,
953
- chat_history=chat_history,
954
- **kwargs,
955
- )
956
-
957
- # Neither deployed nor local runtime available - provide actionable error
958
- raise ValueError(f"{_AGENT_NOT_DEPLOYED_MSG}\n\n{get_local_runtime_missing_message()}")
990
+ runner = self._get_local_runner_or_raise()
991
+ local_kwargs = self._prepare_local_runner_kwargs(message, verbose, runtime_config, chat_history, **kwargs)
992
+ return runner.run(**local_kwargs)
959
993
 
960
994
  async def arun(
961
995
  self,
962
996
  message: str,
963
997
  verbose: bool = False,
964
998
  runtime_config: dict[str, Any] | None = None,
999
+ chat_history: list[dict[str, str]] | None = None,
965
1000
  **kwargs: Any,
966
1001
  ) -> AsyncGenerator[dict, None]:
967
1002
  """Run the agent asynchronously with streaming output.
968
1003
 
1004
+ Supports two execution modes:
1005
+ - **Server-backed**: When the agent is deployed (has an ID), execution
1006
+ happens via the AIP backend server with streaming.
1007
+ - **Local**: When the agent is not deployed and glaip-sdk[local] is installed,
1008
+ execution happens locally via aip-agents (no server required).
1009
+
969
1010
  Args:
970
1011
  message: The message to send to the agent.
971
- verbose: If True, print streaming output to console.
1012
+ verbose: If True, print streaming output to console. Defaults to False.
972
1013
  runtime_config: Optional runtime configuration for tools, MCPs, and agents.
973
1014
  Keys can be SDK objects, UUIDs, or names. Example:
974
1015
  {
@@ -976,20 +1017,44 @@ class Agent:
976
1017
  "mcp_configs": {"mcp-id": {"setting": "on"}},
977
1018
  "agent_config": {"planning": True},
978
1019
  }
1020
+ Defaults to None.
1021
+ chat_history: Optional list of prior conversation messages for context.
1022
+ Each message is a dict with "role" and "content" keys.
1023
+ Defaults to None.
979
1024
  **kwargs: Additional arguments to pass to the run API.
980
1025
 
981
1026
  Yields:
982
1027
  Streaming response chunks from the agent.
983
1028
 
984
1029
  Raises:
985
- ValueError: If the agent hasn't been deployed yet.
986
- RuntimeError: If client is not available.
1030
+ ValueError: If the agent is not deployed and local runtime is not available.
1031
+ RuntimeError: If server-backed execution fails due to client issues.
987
1032
  """
988
- agent_client, call_kwargs = self._prepare_run_kwargs(
989
- message, verbose, runtime_config or kwargs.get("runtime_config"), **kwargs
990
- )
991
- async for chunk in agent_client.arun_agent(**call_kwargs):
992
- yield chunk
1033
+ # Backend routing: deployed agents use server, undeployed use local (if available)
1034
+ if self.id:
1035
+ # Server-backed execution path (agent is deployed)
1036
+ agent_client, call_kwargs = self._prepare_run_kwargs(
1037
+ message, verbose, runtime_config or kwargs.get("runtime_config"), **kwargs
1038
+ )
1039
+ if chat_history is not None:
1040
+ call_kwargs["chat_history"] = chat_history
1041
+
1042
+ async for chunk in agent_client.arun_agent(**call_kwargs):
1043
+ yield chunk
1044
+ return
1045
+
1046
+ # Local execution path (agent is not deployed)
1047
+ runner = self._get_local_runner_or_raise()
1048
+ local_kwargs = self._prepare_local_runner_kwargs(message, verbose, runtime_config, chat_history, **kwargs)
1049
+ result = await runner.arun(**local_kwargs)
1050
+ # Yield a final_response event for consistency with server-backed execution
1051
+ # Include event_type for A2A event shape parity
1052
+ yield {
1053
+ "event_type": "final_response",
1054
+ "metadata": {"kind": "final_response"},
1055
+ "content": result,
1056
+ "is_final": True,
1057
+ }
993
1058
 
994
1059
  def update(self, **kwargs: Any) -> Agent:
995
1060
  """Update the deployed agent with new configuration.
@@ -22,7 +22,7 @@ from glaip_sdk.branding import ACCENT_STYLE, ERROR_STYLE, INFO, NEUTRAL, SUCCESS
22
22
  # Optional import for gitignore support; warn when missing to avoid silent expansion
23
23
  try:
24
24
  import pathspec # type: ignore[import-untyped] # noqa: PLC0415
25
- except ImportError:
25
+ except ImportError: # pragma: no cover - optional dependency
26
26
  pathspec = None # type: ignore[assignment]
27
27
  from glaip_sdk.cli.account_store import get_account_store
28
28
  from glaip_sdk.cli.commands.common_config import check_connection, render_branding_header
@@ -151,79 +151,147 @@ class AccountsController:
151
151
 
152
152
  def _render_textual(self, rows: list[dict[str, str | bool]], store: AccountStore, env_lock: bool) -> None:
153
153
  """Launch the Textual accounts browser."""
154
- callbacks = AccountsTUICallbacks(switch_account=lambda name: self._switch_account(store, name, env_lock))
154
+ active_before = store.get_active_account()
155
+ notified = False
156
+
157
+ def _switch_in_textual(name: str) -> tuple[bool, str]:
158
+ nonlocal notified
159
+ switched, message = self._switch_account(
160
+ store,
161
+ name,
162
+ env_lock,
163
+ emit_console=False,
164
+ invalidate_session=True,
165
+ )
166
+ if switched:
167
+ notified = True
168
+ return switched, message
169
+
170
+ callbacks = AccountsTUICallbacks(switch_account=_switch_in_textual)
155
171
  active = next((row["name"] for row in rows if row.get("active")), None)
156
- run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
157
- # Exit snapshot: surface a success banner when a switch occurred inside the TUI
158
- active_after = store.get_active_account() or "default"
172
+ try:
173
+ run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
174
+ except Exception as exc: # pragma: no cover - defensive around Textual failures
175
+ self.console.print(f"[{WARNING_STYLE}]Accounts browser exited unexpectedly: {exc}[/]")
176
+
177
+ # Exit snapshot: surface a success banner when a switch occurred inside the TUI.
178
+ # Always notify when the active account changed, even if Textual raised.
179
+ active_after = store.get_active_account()
180
+ if active_after != active_before and not notified:
181
+ self._notify_account_switched(active_after)
159
182
  if active_after != active:
160
183
  host_after = ""
161
- account_after = store.get_account(active_after) if hasattr(store, "get_account") else None
184
+ display_account = active_after or "default"
185
+ account_after = store.get_account(display_account) if hasattr(store, "get_account") else None
162
186
  if account_after:
163
187
  host_after = account_after.get("api_url", "")
164
188
  host_suffix = f" • {host_after}" if host_after else ""
165
189
  self.console.print(
166
190
  AIPPanel(
167
- f"[{SUCCESS_STYLE}]Active account ➜ {active_after}[/]{host_suffix}",
191
+ f"[{SUCCESS_STYLE}]Active account ➜ {display_account}[/]{host_suffix}",
168
192
  title="✅ Account Switched",
169
193
  border_style=SUCCESS_STYLE,
170
194
  )
171
195
  )
172
196
 
173
- def _switch_account(self, store: AccountStore, name: str, env_lock: bool) -> tuple[bool, str]:
174
- """Validate and switch active account; returns (success, message)."""
197
+ def _format_connection_error_message(self, error_reason: str, account_name: str, api_url: str) -> str:
198
+ """Format error message for connection validation failures."""
199
+ code, detail = self._parse_error_reason(error_reason)
200
+ if code == "connection_failed":
201
+ return f"Switch aborted: cannot reach {api_url}. Check URL or network."
202
+ if code == "api_failed":
203
+ return f"Switch aborted: API error for '{account_name}'. Check credentials."
204
+ detail_suffix = f": {detail}" if detail else ""
205
+ return f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
206
+
207
+ def _emit_error_message(self, msg: str, style: str = ERROR_STYLE) -> None:
208
+ """Emit an error or warning message to the console."""
209
+ self.console.print(f"[{style}]{msg}[/]")
210
+
211
+ def _validate_account_switch(
212
+ self, store: AccountStore, name: str, env_lock: bool, emit_console: bool
213
+ ) -> tuple[bool, str, dict[str, str] | None]:
214
+ """Validate account switch prerequisites; returns (is_valid, error_msg, account_dict)."""
175
215
  if env_lock:
176
216
  msg = "Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled."
177
- self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
178
- return False, msg
217
+ if emit_console:
218
+ self._emit_error_message(msg, WARNING_STYLE)
219
+ return False, msg, None
179
220
 
180
221
  account = store.get_account(name)
181
222
  if not account:
182
223
  msg = f"Account '{name}' not found."
183
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
184
- return False, msg
224
+ if emit_console:
225
+ self._emit_error_message(msg)
226
+ return False, msg, None
185
227
 
186
228
  api_url = account.get("api_url", "")
187
229
  api_key = account.get("api_key", "")
188
230
  if not api_url or not api_key:
189
231
  edit_cmd = f"aip accounts edit {name}"
190
232
  msg = f"Account '{name}' is missing credentials. Use `/login` or `{edit_cmd}`."
191
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
192
- return False, msg
233
+ if emit_console:
234
+ self._emit_error_message(msg)
235
+ return False, msg, None
193
236
 
194
237
  ok, error_reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
195
238
  if not ok:
196
- code, detail = self._parse_error_reason(error_reason)
197
- if code == "connection_failed":
198
- msg = f"Switch aborted: cannot reach {api_url}. Check URL or network."
199
- elif code == "api_failed":
200
- msg = f"Switch aborted: API error for '{name}'. Check credentials."
201
- else:
202
- detail_suffix = f": {detail}" if detail else ""
203
- msg = f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
204
- self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
205
- return False, msg
239
+ msg = self._format_connection_error_message(error_reason, name, api_url)
240
+ if emit_console:
241
+ self._emit_error_message(msg, WARNING_STYLE)
242
+ return False, msg, None
206
243
 
244
+ return True, "", account
245
+
246
+ def _execute_account_switch(
247
+ self, store: AccountStore, name: str, account: dict[str, str], invalidate_session: bool, emit_console: bool
248
+ ) -> tuple[bool, str]:
249
+ """Execute the account switch and emit success message."""
207
250
  try:
208
251
  store.set_active_account(name)
252
+ api_url = account.get("api_url", "")
253
+ api_key = account.get("api_key", "")
209
254
  masked_key = mask_api_key_display(api_key)
210
- self.console.print(
211
- AIPPanel(
212
- f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
213
- title="✅ Account Switched",
214
- border_style=SUCCESS_STYLE,
255
+ if invalidate_session:
256
+ self._notify_account_switched(name)
257
+ if emit_console:
258
+ self.console.print(
259
+ AIPPanel(
260
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
261
+ title="✅ Account Switched",
262
+ border_style=SUCCESS_STYLE,
263
+ )
215
264
  )
216
- )
217
265
  return True, f"Switched to '{name}'."
218
266
  except AccountStoreError as exc:
219
267
  msg = f"Failed to set active account: {exc}"
220
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
268
+ if emit_console:
269
+ self._emit_error_message(msg)
221
270
  return False, msg
222
271
  except Exception as exc: # NOSONAR(S1045) - catch-all needed for unexpected errors
223
272
  msg = f"Unexpected error while switching to '{name}': {exc}"
224
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
273
+ if emit_console:
274
+ self._emit_error_message(msg)
225
275
  return False, msg
226
276
 
277
+ def _switch_account(
278
+ self,
279
+ store: AccountStore,
280
+ name: str,
281
+ env_lock: bool,
282
+ *,
283
+ emit_console: bool = True,
284
+ invalidate_session: bool = True,
285
+ ) -> tuple[bool, str]:
286
+ """Validate and switch active account; returns (success, message)."""
287
+ is_valid, error_msg, account = self._validate_account_switch(store, name, env_lock, emit_console)
288
+ if not is_valid:
289
+ return False, error_msg
290
+
291
+ if account is None: # Defensive – should never happen, but avoid crashing in production
292
+ return False, "Unable to locate account after validation."
293
+ return self._execute_account_switch(store, name, account, invalidate_session, emit_console)
294
+
227
295
  @staticmethod
228
296
  def _parse_error_reason(reason: str | None) -> tuple[str, str]:
229
297
  """Parse error reason into (code, detail) to avoid fragile substring checks."""
@@ -404,8 +472,18 @@ class AccountsController:
404
472
  except Exception as exc:
405
473
  self.console.print(f"[{WARNING_STYLE}]Account saved but could not set active: {exc}[/]")
406
474
  else:
475
+ self._notify_account_switched(name)
407
476
  self._announce_active_change(store, name)
408
477
 
478
+ def _notify_account_switched(self, name: str | None) -> None:
479
+ """Best-effort notify the hosting session that the active account changed."""
480
+ notify = getattr(self.session, "on_account_switched", None)
481
+ if callable(notify):
482
+ try:
483
+ notify(name)
484
+ except Exception: # pragma: no cover - best-effort callback
485
+ pass
486
+
409
487
  def _confirm_delete_prompt(self, name: str) -> bool:
410
488
  """Ask for delete confirmation; return True when confirmed."""
411
489
  self.console.print(f"[{WARNING_STYLE}]Type '{name}' to confirm deletion. This cannot be undone.[/]")
@@ -38,7 +38,10 @@ class AgentRunSession:
38
38
  self.console = session.console
39
39
  self._agent_id = str(getattr(agent, "id", ""))
40
40
  self._agent_name = getattr(agent, "name", "") or self._agent_id
41
- self._prompt_placeholder: str = "Chat with this agent here; use / for shortcuts. Alt+Enter inserts a newline."
41
+ self._prompt_placeholder: str = (
42
+ "Chat with this agent here; use / for shortcuts. "
43
+ "Alt+Enter inserts a newline. Ctrl+T opens the last transcript."
44
+ )
42
45
  self._contextual_completion_help: dict[str, str] = {
43
46
  "details": "Show this agent's configuration (+ expands prompt).",
44
47
  "help": "Display this context-aware menu.",
@@ -162,6 +162,17 @@ def _create_key_bindings(_session: SlashSession) -> Any:
162
162
  if buffer.complete_state is not None:
163
163
  buffer.cancel_completion()
164
164
 
165
+ @bindings.add("c-t") # type: ignore[misc]
166
+ def _handle_ctrl_t_key(event: Any) -> None: # vulture: ignore
167
+ """Handle Ctrl+T key - open the transcript viewer (when available)."""
168
+ buffer = event.app.current_buffer
169
+ if buffer.complete_state is not None:
170
+ buffer.cancel_completion()
171
+
172
+ open_viewer = getattr(_session, "open_transcript_viewer", None)
173
+ if callable(open_viewer):
174
+ open_viewer(announce=True)
175
+
165
176
  return bindings
166
177
 
167
178
 
@@ -548,7 +548,7 @@ class SlashSession:
548
548
  try:
549
549
  # Use the modern account-aware wizard directly (bypasses legacy config gating)
550
550
  _configure_interactive(account_name=None)
551
- self._config_cache = None
551
+ self.on_account_switched()
552
552
  if self._suppress_login_layout:
553
553
  self._welcome_rendered = False
554
554
  self._default_actions_shown = False
@@ -1211,6 +1211,33 @@ class SlashSession:
1211
1211
  self._client = get_client(self.ctx)
1212
1212
  return self._client
1213
1213
 
1214
+ def on_account_switched(self, _account_name: str | None = None) -> None:
1215
+ """Reset any state that depends on the active account.
1216
+
1217
+ The active account can change via `/accounts` (or other flows that call
1218
+ AccountStore.set_active_account). The slash session caches a configured
1219
+ client instance, so we must invalidate it to avoid leaking the previous
1220
+ account's API URL/key into subsequent commands like `/agents` or `/runs`.
1221
+
1222
+ This method clears:
1223
+ - Client and config cache (account-specific credentials)
1224
+ - Current agent and recent agents (agent data is account-scoped)
1225
+ - Runs pagination state (runs are account-scoped)
1226
+ - Active renderer and transcript ready state (UI state tied to account context)
1227
+ - Contextual commands (may be account-specific)
1228
+
1229
+ These broader resets ensure a clean slate when switching accounts, preventing
1230
+ stale data from the previous account from appearing in the new account's context.
1231
+ """
1232
+ self._client = None
1233
+ self._config_cache = None
1234
+ self._current_agent = None
1235
+ self.recent_agents = []
1236
+ self._runs_pagination_state.clear()
1237
+ self.clear_active_renderer()
1238
+ self.clear_agent_transcript_ready()
1239
+ self.set_contextual_commands(None)
1240
+
1214
1241
  def set_contextual_commands(self, commands: dict[str, str] | None, *, include_global: bool = True) -> None:
1215
1242
  """Set context-specific commands that should appear in completions."""
1216
1243
  self._contextual_commands = dict(commands or {})
@@ -1340,14 +1367,16 @@ class SlashSession:
1340
1367
  f"[{ACCENT_STYLE}]{agent_info['id']}[/]"
1341
1368
  )
1342
1369
  status_line = f"[{SUCCESS_STYLE}]ready[/]"
1343
- status_line += " · transcript ready" if transcript_status["transcript_ready"] else " · transcript pending"
1370
+ if not transcript_status["has_transcript"]:
1371
+ status_line += " · no transcript"
1372
+ elif transcript_status["transcript_ready"]:
1373
+ status_line += " · transcript ready"
1374
+ else:
1375
+ status_line += " · transcript pending"
1344
1376
  header_grid.add_row(primary_line, status_line)
1345
1377
 
1346
1378
  if agent_info["description"]:
1347
- description = agent_info["description"]
1348
- if not transcript_status["transcript_ready"]:
1349
- description = f"{description} (transcript pending)"
1350
- header_grid.add_row(f"[dim]{description}[/dim]", "")
1379
+ header_grid.add_row(f"[dim]{agent_info['description']}[/dim]", "")
1351
1380
 
1352
1381
  return header_grid
1353
1382
 
@@ -684,6 +684,10 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
684
684
  return
685
685
  self.exit()
686
686
 
687
+ def action_app_exit(self) -> None:
688
+ """Exit the application regardless of focus state."""
689
+ self.exit()
690
+
687
691
  def on_button_pressed(self, event: Button.Pressed) -> None:
688
692
  """Handle filter bar buttons."""
689
693
  if event.button.id == "filter-clear":
@@ -23,6 +23,7 @@ except Exception: # pragma: no cover - optional dependency
23
23
  from glaip_sdk.cli.transcript.cache import suggest_filename
24
24
  from glaip_sdk.cli.utils import prompt_export_choice_questionary, questionary_safe_ask
25
25
  from glaip_sdk.utils.rendering.layout.progress import is_delegation_tool
26
+ from glaip_sdk.utils.rendering.layout.transcript import DEFAULT_TRANSCRIPT_THEME
26
27
  from glaip_sdk.utils.rendering.viewer import (
27
28
  ViewerContext as PresenterViewerContext,
28
29
  prepare_viewer_snapshot as presenter_prepare_viewer_snapshot,
@@ -93,8 +94,9 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
93
94
  if self._view_mode == "default":
94
95
  presenter_render_post_run_view(self.console, self.ctx)
95
96
  else:
96
- snapshot, state = presenter_prepare_viewer_snapshot(self.ctx, glyphs=None)
97
- presenter_render_transcript_view(self.console, snapshot)
97
+ theme = DEFAULT_TRANSCRIPT_THEME
98
+ snapshot, state = presenter_prepare_viewer_snapshot(self.ctx, glyphs=None, theme=theme)
99
+ presenter_render_transcript_view(self.console, snapshot, theme=theme)
98
100
  presenter_render_transcript_events(self.console, state.events)
99
101
 
100
102
  # ------------------------------------------------------------------
glaip_sdk/client/tools.py CHANGED
@@ -562,12 +562,14 @@ class ToolClient(BaseClient):
562
562
  ) -> Tool:
563
563
  """Find tool by name and update, or create if not found."""
564
564
  existing = self.find_tools(name)
565
+ name_lower = name.lower()
566
+ exact_matches = [tool for tool in existing if tool.name and tool.name.lower() == name_lower]
565
567
 
566
- if len(existing) == 1:
568
+ if len(exact_matches) == 1:
567
569
  logger.info("Updating existing tool: %s", name)
568
- return self._do_tool_upsert_update(existing[0].id, name, code, description, framework, **kwargs)
570
+ return self._do_tool_upsert_update(exact_matches[0].id, name, code, description, framework, **kwargs)
569
571
 
570
- if len(existing) > 1:
572
+ if len(exact_matches) > 1:
571
573
  raise ValueError(f"Multiple tools found with name '{name}'")
572
574
 
573
575
  # Create new tool - code is required
@@ -160,13 +160,14 @@ class ToolRegistry(BaseRegistry["Tool"]):
160
160
  True if ref is a custom tool that needs uploading.
161
161
  """
162
162
  try:
163
- from glaip_sdk.utils.tool_detection import is_langchain_tool # noqa: PLC0415
164
-
165
- return is_langchain_tool(ref)
163
+ from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
164
+ is_langchain_tool,
165
+ )
166
166
  except ImportError:
167
- # Handle case where langchain_core is not available or tool_detection import fails
168
167
  return False
169
168
 
169
+ return is_langchain_tool(ref)
170
+
170
171
  def resolve(self, ref: Any) -> Tool:
171
172
  """Resolve a tool reference to a platform Tool object.
172
173
 
@@ -18,6 +18,7 @@ Example:
18
18
  from __future__ import annotations
19
19
 
20
20
  import asyncio
21
+ import inspect
21
22
  from dataclasses import dataclass
22
23
  from typing import TYPE_CHECKING, Any
23
24
 
@@ -30,6 +31,8 @@ from glaip_sdk.utils.a2a import A2AEventStreamProcessor
30
31
  from gllm_core.utils import LoggerManager
31
32
 
32
33
  if TYPE_CHECKING:
34
+ from langchain_core.messages import BaseMessage
35
+
33
36
  from glaip_sdk.agents.base import Agent
34
37
 
35
38
  logger = LoggerManager().get_logger(__name__)
@@ -38,6 +41,42 @@ logger = LoggerManager().get_logger(__name__)
38
41
  _event_processor = A2AEventStreamProcessor()
39
42
 
40
43
 
44
+ def _convert_chat_history_to_messages(
45
+ chat_history: list[dict[str, str]] | None,
46
+ ) -> list[BaseMessage]:
47
+ """Convert chat history dicts to LangChain messages.
48
+
49
+ Args:
50
+ chat_history: List of dicts with "role" and "content" keys.
51
+ Supported roles: "user"/"human", "assistant"/"ai", "system".
52
+
53
+ Returns:
54
+ List of LangChain BaseMessage instances.
55
+ """
56
+ if not chat_history:
57
+ return []
58
+
59
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage # noqa: PLC0415
60
+
61
+ messages: list[BaseMessage] = []
62
+ for msg in chat_history:
63
+ role = msg.get("role", "").lower()
64
+ content = msg.get("content", "")
65
+
66
+ if role in ("user", "human"):
67
+ messages.append(HumanMessage(content=content))
68
+ elif role in ("assistant", "ai"):
69
+ messages.append(AIMessage(content=content))
70
+ elif role == "system":
71
+ messages.append(SystemMessage(content=content))
72
+ else:
73
+ # Default to human message for unknown roles
74
+ logger.warning("Unknown chat history role '%s', treating as user message", role)
75
+ messages.append(HumanMessage(content=content))
76
+
77
+ return messages
78
+
79
+
41
80
  @dataclass(frozen=True, slots=True)
42
81
  class LangGraphRunner(BaseRunner):
43
82
  """Runner implementation using aip-agents LangGraphReactAgent.
@@ -58,8 +97,8 @@ class LangGraphRunner(BaseRunner):
58
97
  agent: Agent,
59
98
  message: str,
60
99
  verbose: bool = False,
61
- runtime_config: dict[str, Any] | None = None, # noqa: ARG002 - Used in PR-04+
62
- chat_history: (list[dict[str, str]] | None) = None, # noqa: ARG002 - Used in PR-03
100
+ runtime_config: dict[str, Any] | None = None,
101
+ chat_history: list[dict[str, str]] | None = None,
63
102
  **kwargs: Any,
64
103
  ) -> str:
65
104
  """Execute agent synchronously and return final response text.
@@ -72,7 +111,8 @@ class LangGraphRunner(BaseRunner):
72
111
  runtime_config: Optional runtime configuration for tools, MCPs, etc.
73
112
  Defaults to None. (Implemented in PR-04+)
74
113
  chat_history: Optional list of prior conversation messages.
75
- Defaults to None. (Implemented in PR-03)
114
+ Each message is a dict with "role" and "content" keys.
115
+ Defaults to None.
76
116
  **kwargs: Additional keyword arguments passed to the backend.
77
117
 
78
118
  Returns:
@@ -85,23 +125,34 @@ class LangGraphRunner(BaseRunner):
85
125
  if not check_local_runtime_available():
86
126
  raise RuntimeError(get_local_runtime_missing_message())
87
127
 
88
- return asyncio.run(
89
- self._arun_internal(
90
- agent=agent,
91
- message=message,
92
- verbose=verbose,
93
- runtime_config=runtime_config,
94
- **kwargs,
128
+ try:
129
+ asyncio.get_running_loop()
130
+ except RuntimeError:
131
+ pass
132
+ else:
133
+ raise RuntimeError(
134
+ "LangGraphRunner.run() cannot be called from a running event loop. "
135
+ "Use 'await LangGraphRunner.arun(...)' instead."
95
136
  )
137
+
138
+ coro = self._arun_internal(
139
+ agent=agent,
140
+ message=message,
141
+ verbose=verbose,
142
+ runtime_config=runtime_config,
143
+ chat_history=chat_history,
144
+ **kwargs,
96
145
  )
97
146
 
147
+ return asyncio.run(coro)
148
+
98
149
  async def arun(
99
150
  self,
100
151
  agent: Agent,
101
152
  message: str,
102
153
  verbose: bool = False,
103
154
  runtime_config: dict[str, Any] | None = None,
104
- chat_history: (list[dict[str, str]] | None) = None, # noqa: ARG002 - Used in PR-03
155
+ chat_history: list[dict[str, str]] | None = None,
105
156
  **kwargs: Any,
106
157
  ) -> str:
107
158
  """Execute agent asynchronously and return final response text.
@@ -114,7 +165,8 @@ class LangGraphRunner(BaseRunner):
114
165
  runtime_config: Optional runtime configuration for tools, MCPs, etc.
115
166
  Defaults to None. (Implemented in PR-04+)
116
167
  chat_history: Optional list of prior conversation messages.
117
- Defaults to None. (Implemented in PR-03)
168
+ Each message is a dict with "role" and "content" keys.
169
+ Defaults to None.
118
170
  **kwargs: Additional keyword arguments passed to the backend.
119
171
 
120
172
  Returns:
@@ -128,6 +180,7 @@ class LangGraphRunner(BaseRunner):
128
180
  message=message,
129
181
  verbose=verbose,
130
182
  runtime_config=runtime_config,
183
+ chat_history=chat_history,
131
184
  **kwargs,
132
185
  )
133
186
 
@@ -137,6 +190,7 @@ class LangGraphRunner(BaseRunner):
137
190
  message: str,
138
191
  verbose: bool = False,
139
192
  runtime_config: dict[str, Any] | None = None,
193
+ chat_history: list[dict[str, str]] | None = None,
140
194
  **kwargs: Any,
141
195
  ) -> str:
142
196
  """Internal async implementation of agent execution.
@@ -146,6 +200,7 @@ class LangGraphRunner(BaseRunner):
146
200
  message: The user message to send to the agent.
147
201
  verbose: If True, emit debug trace output during execution.
148
202
  runtime_config: Optional runtime configuration for tools, MCPs, etc.
203
+ chat_history: Optional list of prior conversation messages.
149
204
  **kwargs: Additional keyword arguments passed to the backend.
150
205
 
151
206
  Returns:
@@ -154,6 +209,16 @@ class LangGraphRunner(BaseRunner):
154
209
  # Build the local LangGraphReactAgent from the glaip_sdk Agent
155
210
  local_agent = self.build_langgraph_agent(agent, runtime_config=runtime_config)
156
211
 
212
+ # Convert chat history to LangChain messages for the agent
213
+ langchain_messages = _convert_chat_history_to_messages(chat_history)
214
+ if langchain_messages:
215
+ kwargs["messages"] = langchain_messages
216
+ logger.debug(
217
+ "Passing %d chat history messages to agent '%s'",
218
+ len(langchain_messages),
219
+ agent.name,
220
+ )
221
+
157
222
  # Collect A2AEvents from the stream and extract final response
158
223
  events: list[dict[str, Any]] = []
159
224
 
@@ -187,6 +252,9 @@ class LangGraphRunner(BaseRunner):
187
252
  from glaip_sdk.runner.tool_adapter import LangChainToolAdapter # noqa: PLC0415
188
253
 
189
254
  # Adapt tools for local execution
255
+ # NOTE: CLI parity waiver - local tool execution is SDK-only for MVP.
256
+ # See specs/f/local-agent-runtime/plan.md: "CLI parity is explicitly deferred
257
+ # and will require SDK Technical Lead sign-off per constitution principle IV."
190
258
  langchain_tools: list[Any] = []
191
259
  if agent.tools:
192
260
  adapter = LangChainToolAdapter()
@@ -255,14 +323,7 @@ class LangGraphRunner(BaseRunner):
255
323
 
256
324
  sub_agent_instances = []
257
325
  for sub_agent in sub_agents:
258
- if getattr(sub_agent, "_lookup_only", False):
259
- agent_name = getattr(sub_agent, "name", "<unknown>")
260
- raise ValueError(
261
- f"Sub-agent '{agent_name}' is not supported in local mode. "
262
- "Platform agents (from_id, from_native) cannot be used as "
263
- "sub-agents in local execution. "
264
- "Define the sub-agent locally with Agent(name=..., instruction=...) instead."
265
- )
326
+ self._validate_sub_agent_for_local_mode(sub_agent)
266
327
  sub_agent_instances.append(self.build_langgraph_agent(sub_agent, runtime_config))
267
328
  return sub_agent_instances
268
329
 
@@ -286,16 +347,12 @@ class LangGraphRunner(BaseRunner):
286
347
 
287
348
  mcp_adapter = LangChainMCPAdapter()
288
349
  base_mcp_configs = mcp_adapter.adapt_mcps(agent.mcps)
289
- logger.debug("Base MCP configs from adapter: %s", base_mcp_configs)
290
350
 
291
351
  # Apply merged mcp_configs overrides (agent definition + runtime)
292
- logger.debug("Merged mcp_configs to apply: %s", merged_mcp_configs)
293
352
  if merged_mcp_configs:
294
353
  base_mcp_configs = self._apply_runtime_mcp_configs(base_mcp_configs, merged_mcp_configs)
295
- logger.debug("MCP configs after override: %s", base_mcp_configs)
296
354
 
297
355
  if base_mcp_configs:
298
- logger.info("MCP configs being sent to aip-agents: %s", base_mcp_configs)
299
356
  local_agent.add_mcp_server(base_mcp_configs)
300
357
  logger.debug(
301
358
  "Registered %d MCP server(s) for agent '%s'",
@@ -361,6 +418,7 @@ class LangGraphRunner(BaseRunner):
361
418
  Returns:
362
419
  Agent-specific config dict, or empty dict if not found.
363
420
  """
421
+ from glaip_sdk.utils.resource_refs import is_uuid # noqa: PLC0415
364
422
  from glaip_sdk.utils.runtime_config import get_name_from_key # noqa: PLC0415
365
423
 
366
424
  # Reserved keys at the top level
@@ -371,6 +429,14 @@ class LangGraphRunner(BaseRunner):
371
429
  if key in reserved_keys:
372
430
  continue # Skip global configs
373
431
 
432
+ if isinstance(key, str) and is_uuid(key):
433
+ logger.warning(
434
+ "UUID agent override key '%s' is not supported in local mode; skipping. "
435
+ "Use agent name string or Agent instance as the key instead.",
436
+ key,
437
+ )
438
+ continue
439
+
374
440
  # Check if this key matches the agent
375
441
  try:
376
442
  key_name = get_name_from_key(key)
@@ -579,6 +645,49 @@ class LangGraphRunner(BaseRunner):
579
645
 
580
646
  return merged
581
647
 
648
+ def _validate_sub_agent_for_local_mode(self, sub_agent: Any) -> None:
649
+ """Validate that a sub-agent reference is supported for local execution.
650
+
651
+ Args:
652
+ sub_agent: The sub-agent reference to validate.
653
+
654
+ Raises:
655
+ ValueError: If the sub-agent is not supported in local mode.
656
+ """
657
+ # String references are allowed by SDK API but not for local mode
658
+ if isinstance(sub_agent, str):
659
+ raise ValueError(
660
+ f"Sub-agent '{sub_agent}' is a string reference and cannot be used in local mode. "
661
+ "String sub-agent references are only supported for server execution. "
662
+ "For local mode, define the sub-agent with Agent(name=..., instruction=...)."
663
+ )
664
+
665
+ # Validate sub-agent is not a class
666
+ if inspect.isclass(sub_agent):
667
+ raise ValueError(
668
+ f"Sub-agent '{sub_agent.__name__}' is a class, not an instance. "
669
+ "Local mode requires Agent INSTANCES. "
670
+ "Did you forget to instantiate it? e.g., Agent(...), not Agent"
671
+ )
672
+
673
+ # Validate sub-agent is an Agent-like object (has required attributes)
674
+ if not hasattr(sub_agent, "name") or not hasattr(sub_agent, "instruction"):
675
+ raise ValueError(
676
+ f"Sub-agent {type(sub_agent).__name__} is not supported in local mode. "
677
+ "Local mode requires Agent instances with 'name' and 'instruction' attributes. "
678
+ "Define the sub-agent with Agent(name=..., instruction=...)."
679
+ )
680
+
681
+ # Validate sub-agent is not platform-only (from_id, from_native)
682
+ if getattr(sub_agent, "_lookup_only", False):
683
+ agent_name = getattr(sub_agent, "name", "<unknown>")
684
+ raise ValueError(
685
+ f"Sub-agent '{agent_name}' is not supported in local mode. "
686
+ "Platform agents (from_id, from_native) cannot be used as "
687
+ "sub-agents in local execution. "
688
+ "Define the sub-agent locally with Agent(name=..., instruction=...) instead."
689
+ )
690
+
582
691
  def _log_event(self, event: dict[str, Any]) -> None:
583
692
  """Log an A2AEvent for verbose debug output.
584
693
 
@@ -9,10 +9,9 @@ Authors:
9
9
 
10
10
  from typing import Any
11
11
 
12
- from gllm_core.utils import LoggerManager
13
-
14
12
  from glaip_sdk.runner.mcp_adapter.base_mcp_adapter import BaseMCPAdapter
15
13
  from glaip_sdk.runner.mcp_adapter.mcp_config_builder import MCPConfigBuilder
14
+ from gllm_core.utils import LoggerManager
16
15
 
17
16
  logger = LoggerManager().get_logger(__name__)
18
17
 
@@ -115,17 +114,117 @@ class LangChainMCPAdapter(BaseMCPAdapter):
115
114
  if "server_url" in config and "url" not in config:
116
115
  config["url"] = config.pop("server_url")
117
116
 
117
+ self._validate_converted_config(
118
+ mcp_name=mcp.name,
119
+ transport=mcp.transport,
120
+ config=config,
121
+ )
122
+
118
123
  # Convert authentication to headers using MCPConfigBuilder
124
+ # Merge with existing headers (auth headers take precedence for conflicts)
119
125
  if hasattr(mcp, "authentication") and mcp.authentication:
120
- headers = MCPConfigBuilder.build_headers_from_auth(mcp.authentication)
121
- if headers:
122
- config["headers"] = headers
126
+ auth_headers = MCPConfigBuilder.build_headers_from_auth(mcp.authentication)
127
+ if auth_headers:
128
+ existing_headers = config.get("headers", {})
129
+ config["headers"] = {**existing_headers, **auth_headers}
123
130
  else:
124
131
  logger.warning("Failed to build headers from authentication for MCP '%s'", mcp.name)
125
132
 
126
133
  logger.debug("Converted MCP '%s' with transport '%s'", mcp.name, mcp.transport)
127
134
  return config
128
135
 
136
+ def _validate_converted_config(self, mcp_name: str, transport: str, config: dict[str, Any]) -> None:
137
+ """Validate converted MCP config matches aip-agents schema expectations.
138
+
139
+ This method performs transport-specific validation after the glaip-sdk MCP
140
+ has been converted into the `aip-agents` `mcp_config` dictionary.
141
+
142
+ Args:
143
+ mcp_name: The MCP server name.
144
+ transport: The MCP transport type.
145
+ config: The converted MCP configuration dictionary.
146
+
147
+ Raises:
148
+ ValueError: If the configuration is invalid for the chosen transport.
149
+ """
150
+ self._validate_transport_config(mcp_name, transport)
151
+ if transport in ("http", "sse"):
152
+ self._validate_http_sse_config(
153
+ mcp_name=mcp_name,
154
+ transport=transport,
155
+ config=config,
156
+ )
157
+ return
158
+ if transport == "stdio":
159
+ self._validate_stdio_config(
160
+ mcp_name=mcp_name,
161
+ config=config,
162
+ )
163
+
164
+ def _validate_transport_config(self, mcp_name: str, transport: str) -> None:
165
+ """Validate that the MCP transport is supported by local mode.
166
+
167
+ Args:
168
+ mcp_name: The MCP server name.
169
+ transport: The MCP transport type.
170
+
171
+ Raises:
172
+ ValueError: If the transport is not one of 'http', 'sse', or 'stdio'.
173
+ """
174
+ if transport not in ("http", "sse", "stdio"):
175
+ raise ValueError(
176
+ f"Invalid MCP config for '{mcp_name}': transport must be one of "
177
+ f"'http', 'sse', or 'stdio'. Got: {transport!r}"
178
+ )
179
+
180
+ def _validate_http_sse_config(self, mcp_name: str, transport: str, config: dict[str, Any]) -> None:
181
+ """Validate http/sse config has a usable URL.
182
+
183
+ Args:
184
+ mcp_name: The MCP server name.
185
+ transport: The MCP transport type ('http' or 'sse').
186
+ config: The converted MCP configuration dictionary.
187
+
188
+ Raises:
189
+ ValueError: If url is missing/empty or does not use http(s) scheme.
190
+ """
191
+ url = config.get("url")
192
+ if not isinstance(url, str) or not url:
193
+ raise ValueError(
194
+ f"Invalid MCP config for '{mcp_name}': transport='{transport}' "
195
+ "requires config['url'] as a non-empty string."
196
+ )
197
+
198
+ if not (url.startswith("http://") or url.startswith("https://")):
199
+ raise ValueError(
200
+ f"Invalid MCP config for '{mcp_name}': config['url'] must start with "
201
+ f"'http://' or 'https://'. Got: {url!r}"
202
+ )
203
+
204
+ def _validate_stdio_config(self, mcp_name: str, config: dict[str, Any]) -> None:
205
+ """Validate stdio config has a usable command and optional args list.
206
+
207
+ Args:
208
+ mcp_name: The MCP server name.
209
+ config: The converted MCP configuration dictionary.
210
+
211
+ Raises:
212
+ ValueError: If command is missing/empty or args is not a list of strings.
213
+ """
214
+ command = config.get("command")
215
+ if not isinstance(command, str) or not command:
216
+ raise ValueError(
217
+ f"Invalid MCP config for '{mcp_name}': transport='stdio' "
218
+ "requires config['command'] as a non-empty string."
219
+ )
220
+
221
+ args = config.get("args")
222
+ if args is not None and (not isinstance(args, list) or any(not isinstance(x, str) for x in args)):
223
+ raise ValueError(
224
+ f"Invalid MCP config for '{mcp_name}': transport='stdio' expects "
225
+ "config['args'] to be a list[str] if provided."
226
+ )
227
+
129
228
  def _is_platform_mcp(self, ref: Any) -> bool:
130
229
  """Check if ref is platform-specific (not supported locally)."""
131
230
  # MCP.from_native() or MCP.from_id() instances
@@ -9,12 +9,14 @@ Authors:
9
9
 
10
10
  from typing import Any
11
11
 
12
- from gllm_core.utils import LoggerManager
13
-
14
12
  from glaip_sdk.runner.tool_adapter.base_tool_adapter import BaseToolAdapter
13
+ from gllm_core.utils import LoggerManager
15
14
 
16
15
  logger = LoggerManager().get_logger(__name__)
17
16
 
17
+ # Constant for unknown tool name placeholder
18
+ _UNKNOWN_TOOL_NAME = "<unknown>"
19
+
18
20
 
19
21
  class LangChainToolAdapter(BaseToolAdapter):
20
22
  """Adapts glaip-sdk tools to LangChain BaseTool format for aip-agents.
@@ -81,6 +83,15 @@ class LangChainToolAdapter(BaseToolAdapter):
81
83
  "Local mode only supports LangChain BaseTool classes/instances."
82
84
  )
83
85
 
86
+ def _has_explicit_attr(self, ref: Any, attr: str) -> bool:
87
+ """Check if attribute is explicitly set on the object.
88
+
89
+ This avoids false positives from objects like MagicMock, where hasattr()
90
+ can return True even if the attribute was never set.
91
+ """
92
+ ref_dict = getattr(ref, "__dict__", None)
93
+ return isinstance(ref_dict, dict) and attr in ref_dict
94
+
84
95
  def _is_tool_wrapper(self, ref: Any) -> bool:
85
96
  """Check if ref is a Tool.from_langchain() wrapper.
86
97
 
@@ -90,7 +101,13 @@ class LangChainToolAdapter(BaseToolAdapter):
90
101
  Returns:
91
102
  True if ref is a Tool.from_langchain() wrapper.
92
103
  """
93
- return hasattr(ref, "langchain_tool") and hasattr(ref, "id") and hasattr(ref, "name")
104
+ if self._has_explicit_attr(ref, "langchain_tool") and hasattr(ref, "id") and hasattr(ref, "name"):
105
+ return True
106
+
107
+ if self._has_explicit_attr(ref, "tool_class"):
108
+ return getattr(ref, "tool_class", None) is not None
109
+
110
+ return False
94
111
 
95
112
  def _extract_from_wrapper(self, wrapper: Any) -> Any:
96
113
  """Extract underlying LangChain tool from Tool.from_langchain().
@@ -100,8 +117,29 @@ class LangChainToolAdapter(BaseToolAdapter):
100
117
 
101
118
  Returns:
102
119
  LangChain BaseTool instance.
120
+
121
+ Raises:
122
+ ValueError: If the wrapper's underlying tool is not a valid LangChain tool.
103
123
  """
104
- langchain_tool = wrapper.langchain_tool
124
+ langchain_tool = getattr(wrapper, "langchain_tool", None)
125
+ if langchain_tool is None:
126
+ langchain_tool = getattr(wrapper, "tool_class", None)
127
+
128
+ # Validate the extracted object is a valid LangChain tool
129
+ if langchain_tool is None:
130
+ wrapper_name = getattr(wrapper, "name", _UNKNOWN_TOOL_NAME)
131
+ raise ValueError(
132
+ f"Tool wrapper '{wrapper_name}' does not contain a valid LangChain tool. "
133
+ "Ensure Tool.from_langchain() was called with a LangChain BaseTool class or instance."
134
+ )
135
+
136
+ # Validate it's actually a LangChain tool (class or instance)
137
+ if not self._is_langchain_tool(langchain_tool):
138
+ wrapper_name = getattr(wrapper, "name", _UNKNOWN_TOOL_NAME)
139
+ raise ValueError(
140
+ f"Tool wrapper '{wrapper_name}' contains an invalid tool type: {type(langchain_tool)}. "
141
+ "Expected a LangChain BaseTool class or instance."
142
+ )
105
143
 
106
144
  # If it's a class, instantiate it
107
145
  if isinstance(langchain_tool, type):
@@ -109,7 +147,7 @@ class LangChainToolAdapter(BaseToolAdapter):
109
147
 
110
148
  logger.debug(
111
149
  "Extracted LangChain tool from wrapper: %s",
112
- getattr(langchain_tool, "name", "<unknown>"),
150
+ getattr(langchain_tool, "name", _UNKNOWN_TOOL_NAME),
113
151
  )
114
152
  return langchain_tool
115
153
 
@@ -155,8 +193,10 @@ class LangChainToolAdapter(BaseToolAdapter):
155
193
  return True
156
194
 
157
195
  # Tool.from_native() instances
158
- if hasattr(ref, "id") and hasattr(ref, "name") and not hasattr(ref, "langchain_tool"):
159
- return True
196
+ if hasattr(ref, "id") and hasattr(ref, "name") and not self._has_explicit_attr(ref, "langchain_tool"):
197
+ tool_class = getattr(ref, "tool_class", None) if self._has_explicit_attr(ref, "tool_class") else None
198
+ if tool_class is None:
199
+ return True
160
200
 
161
201
  return False
162
202
 
@@ -173,5 +213,7 @@ class LangChainToolAdapter(BaseToolAdapter):
173
213
  get_local_mode_not_supported_for_tool_message,
174
214
  )
175
215
 
176
- tool_name = ref if isinstance(ref, str) else getattr(ref, "name", "<unknown>")
216
+ tool_name = ref if isinstance(ref, str) else getattr(ref, "name", None)
217
+ if tool_name is None:
218
+ tool_name = getattr(getattr(ref, "tool_class", None), "__name__", _UNKNOWN_TOOL_NAME)
177
219
  return get_local_mode_not_supported_for_tool_message(tool_name)
@@ -315,7 +315,7 @@ def normalize_runtime_config_keys(
315
315
 
316
316
 
317
317
  def _get_name_from_class(cls: type) -> str:
318
- """Extract name from a class, handling Pydantic models.
318
+ """Extract name from a class, handling Pydantic models and @property descriptors.
319
319
 
320
320
  Args:
321
321
  cls: The class to extract name from.
@@ -323,9 +323,10 @@ def _get_name_from_class(cls: type) -> str:
323
323
  Returns:
324
324
  The resolved name string.
325
325
  """
326
- # Try class-level name attribute first
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
327
328
  class_name = getattr(cls, "name", None)
328
- if class_name:
329
+ if isinstance(class_name, str) and class_name:
329
330
  return class_name
330
331
 
331
332
  # For Pydantic models, check model_fields for default value
@@ -355,24 +356,26 @@ def get_name_from_key(key: object) -> str | None:
355
356
  Raises:
356
357
  ValueError: If the key cannot be resolved to a valid name.
357
358
  """
358
- # Instance with name attribute
359
- if hasattr(key, "name"):
360
- name = getattr(key, "name", None)
361
- if name:
362
- return name
363
- raise ValueError(f"Unable to resolve config key: {key!r}")
364
-
365
- # Class type (not instance)
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
366
362
  if isinstance(key, type):
367
363
  return _get_name_from_class(key)
368
364
 
369
- # String key
365
+ # String key - check early to avoid attribute access
370
366
  if isinstance(key, str):
371
367
  if is_uuid(key):
372
368
  logger.warning("UUID '%s' not supported in local mode, skipping", key)
373
369
  return None
374
370
  return key
375
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
+
376
379
  raise ValueError(f"Unable to resolve config key: {key!r}")
377
380
 
378
381
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: glaip-sdk
3
- Version: 0.6.5b9
3
+ Version: 0.6.8b1
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
@@ -13,9 +13,9 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Provides-Extra: dev
14
14
  Provides-Extra: memory
15
15
  Provides-Extra: privacy
16
- Requires-Dist: aip-agents-binary (==0.5.0b2)
17
- Requires-Dist: aip-agents[memory] (==0.5.0b2) ; (python_version >= "3.11" and python_version < "3.13") and (extra == "memory")
18
- Requires-Dist: aip-agents[privacy] (==0.5.0b2) ; (python_version >= "3.11" and python_version < "3.13") and (extra == "privacy")
16
+ Requires-Dist: aip-agents-binary (>=0.5.1)
17
+ Requires-Dist: aip-agents[memory] (>=0.5.1) ; (python_version >= "3.11" and python_version < "3.13") and (extra == "memory")
18
+ Requires-Dist: aip-agents[privacy] (>=0.5.1) ; (python_version >= "3.11" and python_version < "3.13") and (extra == "privacy")
19
19
  Requires-Dist: click (>=8.2.0,<8.3.0)
20
20
  Requires-Dist: gllm-core-binary (>=0.1.0)
21
21
  Requires-Dist: gllm-tools-binary (>=0.1.3)
@@ -1,7 +1,7 @@
1
1
  glaip_sdk/__init__.py,sha256=0PAFfodqAEdggIiV1Es_JryDokZrhYLFFIXosqdguJU,420
2
2
  glaip_sdk/_version.py,sha256=5CHGCxx_36fgmMWuEx6jJ2CzzM-i9eBFyQWFwBi23XE,2259
3
3
  glaip_sdk/agents/__init__.py,sha256=VfYov56edbWuySXFEbWJ_jLXgwnFzPk1KB-9-mfsUCc,776
4
- glaip_sdk/agents/base.py,sha256=xCu4vZ2EOXbpsY38Cbebdqz-7ZNmZK9_wo8DxyZh7eA,39577
4
+ glaip_sdk/agents/base.py,sha256=5ZX7bVf64AB1DodBriHrzJg5QYvpTai_vYk6Br0JmAU,42338
5
5
  glaip_sdk/branding.py,sha256=tLqYCIHMkUf8p2smpuAGNptwaKUN38G4mlh0A0DOl_w,7823
6
6
  glaip_sdk/cli/__init__.py,sha256=xCCfuF1Yc7mpCDcfhHZTX0vizvtrDSLeT8MJ3V7m5A0,156
7
7
  glaip_sdk/cli/account_store.py,sha256=TK4iTV93Q1uD9mCY_2ZMT6EazHKU2jX0qhgWfEM4V-4,18459
@@ -11,7 +11,7 @@ glaip_sdk/cli/commands/__init__.py,sha256=6Z3ASXDut0lAbUX_umBFtxPzzFyqoiZfVeTahT
11
11
  glaip_sdk/cli/commands/accounts.py,sha256=J89chwJWWpEv6TBXaGPUJH-aLrM9Ymxp4jywp5YUUEo,24685
12
12
  glaip_sdk/cli/commands/agents.py,sha256=WCOzllyh_Znwlju5camT4vE6OeRJbsAmjWwcyiAqWs4,48429
13
13
  glaip_sdk/cli/commands/common_config.py,sha256=chCa0B5t6JER-pGPzItUK7fk_qQgTwf7bIRU004PrUI,3731
14
- glaip_sdk/cli/commands/configure.py,sha256=95PQiJnpvsdH02v_tLVANd64qAJJnZKlhNe4tpfWIS4,30262
14
+ glaip_sdk/cli/commands/configure.py,sha256=Y3ST1I33rXqlLvUyhKFOl9JUjDe01QCrL1dzOjO1E-c,30304
15
15
  glaip_sdk/cli/commands/mcps.py,sha256=tttqQnfM89iI9Pm94u8YRhiHMQNYNouecFX0brsT4cQ,42551
16
16
  glaip_sdk/cli/commands/models.py,sha256=vfcGprK5CHprQ0CNpNzQlNNTELvdgKC7JxTG_ijOwmE,2009
17
17
  glaip_sdk/cli/commands/tools.py,sha256=_VBqG-vIjnn-gqvDlSTvcU7_F4N3ANGGKEECcQVR-BM,18430
@@ -37,15 +37,15 @@ glaip_sdk/cli/parsers/json_input.py,sha256=kxoxeIlgfsaH2jhe6apZAgSxAtwlpSINLTMRs
37
37
  glaip_sdk/cli/resolution.py,sha256=K-VaEHm9SYY_qfb9538VNHykL4_2N6F8iQqI1zMx_64,2402
38
38
  glaip_sdk/cli/rich_helpers.py,sha256=kO47N8e506rxrN6Oc9mbAWN3Qb536oQPWZy1s9A616g,819
39
39
  glaip_sdk/cli/slash/__init__.py,sha256=J9TPL2UcNTkW8eifG6nRmAEGHhyEgdYMYk4cHaaObC0,386
40
- glaip_sdk/cli/slash/accounts_controller.py,sha256=BDKaEfVbMh1B2T4GW3FF5hJo0SCe9_Pmy8vvyXtI9LY,21562
40
+ glaip_sdk/cli/slash/accounts_controller.py,sha256=-7v_4nTAVCqXySbOLtTfMpUpsqCzDTWmZYkBU880AzI,24803
41
41
  glaip_sdk/cli/slash/accounts_shared.py,sha256=Mq5HxlI0YsVEQ0KKISWvyBZhzOFFWCzwRbhF5xwvUbM,2626
42
- glaip_sdk/cli/slash/agent_session.py,sha256=9r1xNRk5mk6rfJXV6KIf2Yo4B4hjknimd9fkxH1LO3c,11304
43
- glaip_sdk/cli/slash/prompt.py,sha256=2urqR3QqN3O09lHmKKSEbhsIdlS4B7hm9O8AP_VwCSU,8034
42
+ glaip_sdk/cli/slash/agent_session.py,sha256=ZK51zrwhFtun26Lu3a70Kcp3VFh0jwu37crWDKx7Ivk,11377
43
+ glaip_sdk/cli/slash/prompt.py,sha256=q4f1c2zr7ZMUeO6AgOBF2Nz4qgMOXrVPt6WzPRQMbAM,8501
44
44
  glaip_sdk/cli/slash/remote_runs_controller.py,sha256=Ok6CezIeF1CPGQ8-QN3TRx5kGGEACOrgyPwH_BRRCyI,21354
45
- glaip_sdk/cli/slash/session.py,sha256=8pfO21vAbpxWnE71fthXXsR0TijYBDDhHR-8LFFapUs,63028
45
+ glaip_sdk/cli/slash/session.py,sha256=JieIjUCTMW350LDqdSOdfPP8U0OJSmRYvqPBbddO2bw,64333
46
46
  glaip_sdk/cli/slash/tui/__init__.py,sha256=ljBAeAFY2qNDkbJrZh5NgXxjwUlsv9-UxgKNIv0AF1Q,274
47
47
  glaip_sdk/cli/slash/tui/accounts.tcss,sha256=xuQjQ0tBM08K1DUv6lI5Sfu1zgZzQxg60c9-RlEWB4s,1160
48
- glaip_sdk/cli/slash/tui/accounts_app.py,sha256=dE0w10MSGPgjE7YQl8LzF1pSYu6LKX-L6eYMFOM2530,33593
48
+ glaip_sdk/cli/slash/tui/accounts_app.py,sha256=QDaOpVStS6Z51tfXcS8GRRjTrVfMO26-guHepqysU9k,33715
49
49
  glaip_sdk/cli/slash/tui/background_tasks.py,sha256=SAe1mV2vXB3mJcSGhelU950vf8Lifjhws9iomyIVFKw,2422
50
50
  glaip_sdk/cli/slash/tui/loading.py,sha256=nW5pv_Tnl9FUOPR3Qf2O5gt1AGHSo3b5-Uofg34F6AE,1909
51
51
  glaip_sdk/cli/slash/tui/remote_runs_app.py,sha256=RCrI-c5ilKV6Iy1lz2Aok9xo2Ou02vqcXACMXTdodnE,24716
@@ -55,7 +55,7 @@ glaip_sdk/cli/transcript/capture.py,sha256=t8j_62cC6rhb51oCluZd17N04vcXqyjkhPRcR
55
55
  glaip_sdk/cli/transcript/export.py,sha256=reCvrZVzli8_LzYe5ZNdaa-MwZ1ov2RjnDzKZWr_6-E,1117
56
56
  glaip_sdk/cli/transcript/history.py,sha256=2FBjawxP8CX9gRPMUMP8bDjG50BGM2j2zk6IfHvAMH4,26211
57
57
  glaip_sdk/cli/transcript/launcher.py,sha256=z5ivkPXDQJpATIqtRLUK8jH3p3WIZ72PvOPqYRDMJvw,2327
58
- glaip_sdk/cli/transcript/viewer.py,sha256=ar1SzRkhKIf3_DgFz1EG1RZGDmd2w2wogAe038DLL_M,13037
58
+ glaip_sdk/cli/transcript/viewer.py,sha256=HKL3U-FrhluKSmxLdE_kTbdTalG-LCE0wu1MXsf22Ao,13189
59
59
  glaip_sdk/cli/update_notifier.py,sha256=FnTjzS8YT94RmP6c5aU_XNIyRi7FRHvAskMy-VJikl8,10064
60
60
  glaip_sdk/cli/utils.py,sha256=iemmKkpPndoZFBasoVqV7QArplchtr08yYWLA2efMzg,11996
61
61
  glaip_sdk/cli/validators.py,sha256=d-kq4y7HWMo6Gc7wLXWUsCt8JwFvJX_roZqRm1Nko1I,5622
@@ -68,7 +68,7 @@ glaip_sdk/client/main.py,sha256=RTREAOgGouYm4lFKkpNBQ9dmxalnBsIpSSaQLWVFSmU,9054
68
68
  glaip_sdk/client/mcps.py,sha256=gFRuLOGeh6ieIhR4PeD6yNVT6NhvUMTqPq9iuu1vkAY,13019
69
69
  glaip_sdk/client/run_rendering.py,sha256=ubBO-NzyZoYRELNwxVvrQFRGQVJCuLfqqJNiXrBZDoQ,14223
70
70
  glaip_sdk/client/shared.py,sha256=esHlsR0LEfL-pFDaWebQjKKOLl09jsRY-2pllBUn4nU,522
71
- glaip_sdk/client/tools.py,sha256=RR7345wECqPtofDXPW12VOnLtus554tXleL0YHQy82U,22435
71
+ glaip_sdk/client/tools.py,sha256=kK0rBwX1e_5AlGQRjlO6rNz6gDlohhXWdlxN9AwotdE,22585
72
72
  glaip_sdk/client/validators.py,sha256=ioF9VCs-LG2yLkaRDd7Hff74lojDZZ0_Q3CiLbdm1RY,8381
73
73
  glaip_sdk/config/constants.py,sha256=Y03c6op0e7K0jTQ8bmWXhWAqsnjWxkAhWniq8Z0iEKY,1081
74
74
  glaip_sdk/exceptions.py,sha256=iAChFClkytXRBLP0vZq1_YjoZxA9i4m4bW1gDLiGR1g,2321
@@ -87,19 +87,19 @@ glaip_sdk/registry/__init__.py,sha256=mjvElYE-wwmbriGe-c6qy4on0ccEuWxW_EWWrSbptC
87
87
  glaip_sdk/registry/agent.py,sha256=F0axW4BIUODqnttIOzxnoS5AqQkLZ1i48FTeZNnYkhA,5203
88
88
  glaip_sdk/registry/base.py,sha256=0x2ZBhiERGUcf9mQeWlksSYs5TxDG6FxBYQToYZa5D4,4143
89
89
  glaip_sdk/registry/mcp.py,sha256=kNJmiijIbZL9Btx5o2tFtbaT-WG6O4Xf_nl3wz356Ow,7978
90
- glaip_sdk/registry/tool.py,sha256=Aq6eryE9hOF9XFnjaBwfnrQgii5uyMdMkLaqm9D9BEk,7765
90
+ glaip_sdk/registry/tool.py,sha256=rxrVxnO_VwO6E5kccqxxEUC337J9qbKpje-Gwl5a3sY,7699
91
91
  glaip_sdk/rich_components.py,sha256=44Z0V1ZQleVh9gUDGwRR5mriiYFnVGOhm7fFxZYbP8c,4052
92
92
  glaip_sdk/runner/__init__.py,sha256=8RrngoGfpF8x9X27RPdX4gJjch75ZvhtVt_6UV0ULLQ,1615
93
93
  glaip_sdk/runner/base.py,sha256=KIjcSAyDCP9_mn2H4rXR5gu1FZlwD9pe0gkTBmr6Yi4,2663
94
94
  glaip_sdk/runner/deps.py,sha256=3ZDWyvWu4LFJOGHd18tv3VzVo8NY5gb1VeZIelMknyI,3934
95
- glaip_sdk/runner/langgraph.py,sha256=OPuD44EERJgrlQoM8iGEgGV5pA7e-WeCPHMSe4znYPs,21840
95
+ glaip_sdk/runner/langgraph.py,sha256=N9jhuCI-7dS6gzrsKzG6xT3CGWKuo7GEpEwfszgORVo,26050
96
96
  glaip_sdk/runner/mcp_adapter/__init__.py,sha256=Rdttfg3N6kg3-DaTCKqaGXKByZyBt0Mwf6FV8s_5kI8,462
97
97
  glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py,sha256=ic56fKgb3zgVZZQm3ClWUZi7pE1t4EVq8mOg6AM6hdA,1374
98
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py,sha256=88moaeTDW7KPxu6qh8mK4pr6oqpOLKLgXX66gFB-5J0,5715
98
+ glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py,sha256=b58GuadPz7q7aXoJyTYs0eeJ_oqp-wLR1tcr_5cbV1s,9723
99
99
  glaip_sdk/runner/mcp_adapter/mcp_config_builder.py,sha256=fQcRaueDuyUzXUSVn9N8QxfaYNIteEO_R_uibx_0Icw,3440
100
100
  glaip_sdk/runner/tool_adapter/__init__.py,sha256=scv8sSPxSWjlSNEace03R230YbmWgphLgqINKvDjWmM,480
101
101
  glaip_sdk/runner/tool_adapter/base_tool_adapter.py,sha256=nL--eicV0St5_0PZZSEhRurHDZHNwhGN2cKOUh0C5IY,1400
102
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py,sha256=tmRYyPhih0a4_8c7Cg7ariYP2AEtHW69TRxqukwa2Ko,5438
102
+ glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py,sha256=goSSDOpubuplsKpfemlbesf_bZBdpDKSTqLILvApcjA,7438
103
103
  glaip_sdk/tools/__init__.py,sha256=rhGzEqQFCzeMrxmikBuNrMz4PyYczwic28boDKVmoHs,585
104
104
  glaip_sdk/tools/base.py,sha256=bvumLJ-DiQTmuYKgq2yCnlwrTZ9nYXpOwWU0e1vWR5g,15185
105
105
  glaip_sdk/utils/__init__.py,sha256=ntohV7cxlY2Yksi2nFuFm_Mg2XVJbBbSJVRej7Mi9YE,2770
@@ -148,12 +148,12 @@ glaip_sdk/utils/rendering/viewer/__init__.py,sha256=XrxmE2cMAozqrzo1jtDFm8HqNtvD
148
148
  glaip_sdk/utils/rendering/viewer/presenter.py,sha256=mlLMTjnyeyPVtsyrAbz1BJu9lFGQSlS-voZ-_Cuugv0,5725
149
149
  glaip_sdk/utils/resource_refs.py,sha256=vF34kyAtFBLnaKnQVrsr2st1JiSxVbIZ4yq0DelJvCI,5966
150
150
  glaip_sdk/utils/run_renderer.py,sha256=d_VMI6LbvHPUUeRmGqh5wK_lHqDEIAcym2iqpbtDad0,1365
151
- glaip_sdk/utils/runtime_config.py,sha256=SPvc2qF7qGio745kMwOOtzXY1-pHI3Vi3BJHcHFhwiE,13634
151
+ glaip_sdk/utils/runtime_config.py,sha256=Gl9-CQ4lYZ39vRSgtdfcSU3CXshVDDuTOdSzjvsCgG0,14070
152
152
  glaip_sdk/utils/serialization.py,sha256=z-qpvWLSBrGK3wbUclcA1UIKLXJedTnMSwPdq-FF4lo,13308
153
153
  glaip_sdk/utils/sync.py,sha256=3VKqs1UfNGWSobgRXohBKP7mMMzdUW3SU0bJQ1uxOgw,4872
154
154
  glaip_sdk/utils/tool_detection.py,sha256=g410GNug_PhLye8rd9UU-LVFIKq3jHPbmSItEkLxPTc,807
155
155
  glaip_sdk/utils/validation.py,sha256=hB_k3lvHdIFUiSwHStrC0Eqnhx0OG2UvwqASeem0HuQ,6859
156
- glaip_sdk-0.6.5b9.dist-info/METADATA,sha256=B-iwONbryPtYPCW-0YHWqgSu_9FBuQBhjcFzCiUslYc,7927
157
- glaip_sdk-0.6.5b9.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
158
- glaip_sdk-0.6.5b9.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
159
- glaip_sdk-0.6.5b9.dist-info/RECORD,,
156
+ glaip_sdk-0.6.8b1.dist-info/METADATA,sha256=mwvQlQevc5j99zjtialgGWO62muor8JP13GA0VwRCt4,7921
157
+ glaip_sdk-0.6.8b1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
158
+ glaip_sdk-0.6.8b1.dist-info/entry_points.txt,sha256=EGs8NO8J1fdFMWA3CsF7sKBEvtHb_fujdCoNPhfMouE,47
159
+ glaip_sdk-0.6.8b1.dist-info/RECORD,,