glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.12__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 (127) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +217 -42
  3. glaip_sdk/branding.py +113 -2
  4. glaip_sdk/cli/account_store.py +15 -0
  5. glaip_sdk/cli/auth.py +14 -8
  6. glaip_sdk/cli/commands/accounts.py +1 -1
  7. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  8. glaip_sdk/cli/commands/agents/_common.py +561 -0
  9. glaip_sdk/cli/commands/agents/create.py +151 -0
  10. glaip_sdk/cli/commands/agents/delete.py +64 -0
  11. glaip_sdk/cli/commands/agents/get.py +89 -0
  12. glaip_sdk/cli/commands/agents/list.py +129 -0
  13. glaip_sdk/cli/commands/agents/run.py +264 -0
  14. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  15. glaip_sdk/cli/commands/agents/update.py +112 -0
  16. glaip_sdk/cli/commands/common_config.py +15 -12
  17. glaip_sdk/cli/commands/configure.py +2 -3
  18. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  19. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  20. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  21. glaip_sdk/cli/commands/mcps/create.py +152 -0
  22. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  23. glaip_sdk/cli/commands/mcps/get.py +212 -0
  24. glaip_sdk/cli/commands/mcps/list.py +69 -0
  25. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  26. glaip_sdk/cli/commands/mcps/update.py +190 -0
  27. glaip_sdk/cli/commands/models.py +2 -4
  28. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  29. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  30. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  31. glaip_sdk/cli/commands/tools/_common.py +80 -0
  32. glaip_sdk/cli/commands/tools/create.py +228 -0
  33. glaip_sdk/cli/commands/tools/delete.py +61 -0
  34. glaip_sdk/cli/commands/tools/get.py +103 -0
  35. glaip_sdk/cli/commands/tools/list.py +69 -0
  36. glaip_sdk/cli/commands/tools/script.py +49 -0
  37. glaip_sdk/cli/commands/tools/update.py +102 -0
  38. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  39. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  40. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  41. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  42. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  43. glaip_sdk/cli/commands/update.py +163 -17
  44. glaip_sdk/cli/config.py +1 -0
  45. glaip_sdk/cli/core/output.py +12 -7
  46. glaip_sdk/cli/entrypoint.py +20 -0
  47. glaip_sdk/cli/main.py +127 -39
  48. glaip_sdk/cli/pager.py +3 -3
  49. glaip_sdk/cli/resolution.py +2 -1
  50. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  51. glaip_sdk/cli/slash/agent_session.py +5 -2
  52. glaip_sdk/cli/slash/prompt.py +11 -0
  53. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  54. glaip_sdk/cli/slash/session.py +369 -23
  55. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  56. glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
  57. glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
  58. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  59. glaip_sdk/cli/slash/tui/context.py +87 -0
  60. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  61. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  62. glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
  64. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  65. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  66. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  67. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  68. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  69. glaip_sdk/cli/slash/tui/toast.py +374 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +5 -3
  72. glaip_sdk/cli/tui_settings.py +125 -0
  73. glaip_sdk/cli/update_notifier.py +215 -7
  74. glaip_sdk/cli/validators.py +1 -1
  75. glaip_sdk/client/__init__.py +2 -1
  76. glaip_sdk/client/_schedule_payloads.py +89 -0
  77. glaip_sdk/client/agents.py +50 -8
  78. glaip_sdk/client/hitl.py +136 -0
  79. glaip_sdk/client/main.py +7 -1
  80. glaip_sdk/client/mcps.py +44 -13
  81. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  82. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  83. glaip_sdk/client/payloads/agent/responses.py +43 -0
  84. glaip_sdk/client/run_rendering.py +414 -3
  85. glaip_sdk/client/schedules.py +439 -0
  86. glaip_sdk/client/tools.py +57 -26
  87. glaip_sdk/guardrails/__init__.py +80 -0
  88. glaip_sdk/guardrails/serializer.py +89 -0
  89. glaip_sdk/hitl/__init__.py +48 -0
  90. glaip_sdk/hitl/base.py +64 -0
  91. glaip_sdk/hitl/callback.py +43 -0
  92. glaip_sdk/hitl/local.py +121 -0
  93. glaip_sdk/hitl/remote.py +523 -0
  94. glaip_sdk/models/__init__.py +17 -0
  95. glaip_sdk/models/agent_runs.py +2 -1
  96. glaip_sdk/models/schedule.py +224 -0
  97. glaip_sdk/payload_schemas/agent.py +1 -0
  98. glaip_sdk/payload_schemas/guardrails.py +34 -0
  99. glaip_sdk/registry/tool.py +273 -59
  100. glaip_sdk/runner/__init__.py +20 -3
  101. glaip_sdk/runner/deps.py +5 -8
  102. glaip_sdk/runner/langgraph.py +318 -42
  103. glaip_sdk/runner/logging_config.py +77 -0
  104. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  105. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  106. glaip_sdk/schedules/__init__.py +22 -0
  107. glaip_sdk/schedules/base.py +291 -0
  108. glaip_sdk/tools/base.py +67 -14
  109. glaip_sdk/utils/__init__.py +1 -0
  110. glaip_sdk/utils/bundler.py +138 -2
  111. glaip_sdk/utils/import_resolver.py +43 -11
  112. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  113. glaip_sdk/utils/runtime_config.py +15 -12
  114. glaip_sdk/utils/sync.py +31 -11
  115. glaip_sdk/utils/tool_detection.py +274 -6
  116. glaip_sdk/utils/tool_storage_provider.py +140 -0
  117. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
  118. glaip_sdk-0.7.12.dist-info/RECORD +219 -0
  119. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
  120. glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
  121. glaip_sdk-0.7.12.dist-info/top_level.txt +1 -0
  122. glaip_sdk/cli/commands/agents.py +0 -1509
  123. glaip_sdk/cli/commands/mcps.py +0 -1356
  124. glaip_sdk/cli/commands/tools.py +0 -576
  125. glaip_sdk/cli/utils.py +0 -263
  126. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  127. glaip_sdk-0.6.5b6.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """Schedule DTO models for AIP SDK.
3
+
4
+ These models represent API payloads and responses. They are intentionally DTO-only
5
+ and do not contain runtime behavior.
6
+
7
+ Authors:
8
+ Raymond Christopher (raymond.christopher@gdplabs.id)
9
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
10
+ """
11
+
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, ConfigDict
16
+
17
+ from glaip_sdk.models.agent_runs import RunStatus
18
+
19
+
20
+ class ScheduleConfig(BaseModel):
21
+ """Cron-like schedule configuration matching backend ScheduleConfig.
22
+
23
+ All fields accept cron-style values:
24
+ - Specific values: "0", "9", "1"
25
+ - Wildcards: "*"
26
+ - Intervals: "*/5", "*/2"
27
+ - Ranges: "1-5", "9-17"
28
+ - Lists: "1,3,5"
29
+
30
+ Note: day_of_week uses 0-6 where 0=Monday.
31
+ """
32
+
33
+ minute: str = "*"
34
+ hour: str = "*"
35
+ day_of_month: str = "*"
36
+ month: str = "*"
37
+ day_of_week: str = "*"
38
+
39
+ model_config = ConfigDict(from_attributes=True)
40
+
41
+ def to_cron_string(self) -> str:
42
+ """Convert to standard cron string format.
43
+
44
+ Returns:
45
+ Cron string in format "minute hour day_of_month month day_of_week"
46
+ """
47
+ return f"{self.minute} {self.hour} {self.day_of_month} {self.month} {self.day_of_week}"
48
+
49
+ @classmethod
50
+ def from_cron_string(cls, cron: str) -> "ScheduleConfig":
51
+ """Parse a cron string into ScheduleConfig.
52
+
53
+ Args:
54
+ cron: Cron string in format "minute hour day_of_month month day_of_week"
55
+
56
+ Returns:
57
+ ScheduleConfig instance
58
+
59
+ Raises:
60
+ ValueError: If cron string doesn't have exactly 5 fields
61
+ """
62
+ parts = cron.split()
63
+ if len(parts) != 5:
64
+ raise ValueError(f"Invalid cron string: expected 5 fields, got {len(parts)}")
65
+ return cls(
66
+ minute=parts[0],
67
+ hour=parts[1],
68
+ day_of_month=parts[2],
69
+ month=parts[3],
70
+ day_of_week=parts[4],
71
+ )
72
+
73
+
74
+ class ScheduleMetadata(BaseModel):
75
+ """Metadata embedded in schedule responses.
76
+
77
+ Contains the agent association, input text, and cron configuration.
78
+ """
79
+
80
+ agent_id: str
81
+ input: str
82
+ schedule: ScheduleConfig
83
+
84
+ model_config = ConfigDict(from_attributes=True)
85
+
86
+
87
+ class ScheduleResponse(BaseModel):
88
+ """Schedule response DTO.
89
+
90
+ Attributes:
91
+ id: Schedule ID.
92
+ next_run_time: Next run time as returned by the API.
93
+ time_until_next_run: Human-readable duration until next run.
94
+ metadata: Schedule metadata.
95
+ created_at: Creation timestamp.
96
+ updated_at: Update timestamp.
97
+ agent_id: Agent ID derived from metadata.
98
+ input: Input text derived from metadata.
99
+ schedule_config: ScheduleConfig derived from metadata.
100
+ """
101
+
102
+ id: str
103
+ next_run_time: str | None = None
104
+ time_until_next_run: str | None = None
105
+ metadata: ScheduleMetadata | None = None
106
+ created_at: datetime | None = None
107
+ updated_at: datetime | None = None
108
+
109
+ model_config = ConfigDict(from_attributes=True)
110
+
111
+ @property
112
+ def agent_id(self) -> str | None:
113
+ """Get the agent ID from metadata."""
114
+ return self.metadata.agent_id if self.metadata else None
115
+
116
+ @property
117
+ def input(self) -> str | None:
118
+ """Get the scheduled input text from metadata."""
119
+ return self.metadata.input if self.metadata else None
120
+
121
+ @property
122
+ def schedule_config(self) -> ScheduleConfig | None:
123
+ """Get the schedule configuration from metadata."""
124
+ return self.metadata.schedule if self.metadata else None
125
+
126
+ def __repr__(self) -> str:
127
+ """Return a readable representation of the schedule."""
128
+ parts = [f"ScheduleResponse(id={self.id!r}"]
129
+ if self.next_run_time:
130
+ parts.append(f"next_run_time={self.next_run_time!r}")
131
+ if self.time_until_next_run:
132
+ parts.append(f"time_until_next_run={self.time_until_next_run!r}")
133
+ if self.agent_id:
134
+ parts.append(f"agent_id={self.agent_id!r}")
135
+ if self.created_at:
136
+ parts.append(f"created_at={self.created_at!r}")
137
+ return ", ".join(parts) + ")"
138
+
139
+ def __str__(self) -> str:
140
+ """Return a readable string representation."""
141
+ return self.__repr__()
142
+
143
+
144
+ # Type alias for SSE event dictionaries
145
+ ScheduleRunOutputChunk = dict[str, Any]
146
+
147
+
148
+ class ScheduleRunResponse(BaseModel):
149
+ """Schedule run response DTO."""
150
+
151
+ id: str
152
+ agent_id: str
153
+ schedule_id: str | None = None # May be None for non-scheduled runs
154
+ status: RunStatus # Backend uses lowercase.
155
+ run_type: str | None = None # "schedule" for scheduled runs
156
+ started_at: datetime | None = None
157
+ completed_at: datetime | None = None
158
+ input: str | None = None # Input used for the execution.
159
+ config: ScheduleConfig | dict[str, str] | None = None # Schedule config used.
160
+ created_at: datetime | None = None
161
+ updated_at: datetime | None = None
162
+
163
+ model_config = ConfigDict(from_attributes=True, extra="ignore")
164
+
165
+ @property
166
+ def duration(self) -> str | None:
167
+ """Calculate the duration of the run.
168
+
169
+ Returns:
170
+ Formatted duration string (HH:MM:SS) or None if incomplete
171
+ """
172
+ if self.started_at and self.completed_at:
173
+ delta = self.completed_at - self.started_at
174
+ total_seconds = int(delta.total_seconds())
175
+ hours, remainder = divmod(total_seconds, 3600)
176
+ minutes, seconds = divmod(remainder, 60)
177
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
178
+ return None
179
+
180
+ def __repr__(self) -> str:
181
+ """Return a readable representation of the run."""
182
+ parts = [f"ScheduleRunResponse(id={self.id!r}"]
183
+ parts.append(f"status={self.status!r}")
184
+ if self.started_at:
185
+ parts.append(f"started_at={self.started_at.isoformat()!r}")
186
+ if self.duration:
187
+ parts.append(f"duration={self.duration!r}")
188
+ return ", ".join(parts) + ")"
189
+
190
+ def __str__(self) -> str:
191
+ """Return a readable string representation."""
192
+ return self.__repr__()
193
+
194
+
195
+ class ScheduleRunResult(BaseModel):
196
+ """Full output payload for a schedule run.
197
+
198
+ Maps to the backend's AgentRunWithOutputResponse which includes
199
+ run metadata plus the output stream.
200
+ """
201
+
202
+ id: str
203
+ agent_id: str
204
+ schedule_id: str | None = None
205
+ status: RunStatus
206
+ run_type: str | None = None
207
+ started_at: datetime | None = None
208
+ completed_at: datetime | None = None
209
+ input: str | None = None # Input used for the execution.
210
+ config: ScheduleConfig | dict[str, str] | None = None # Schedule config used.
211
+ output: list[ScheduleRunOutputChunk] | None = None
212
+ created_at: datetime | None = None
213
+ updated_at: datetime | None = None
214
+
215
+ model_config = ConfigDict(from_attributes=True, extra="ignore")
216
+
217
+ def __repr__(self) -> str:
218
+ """Return a readable representation of the result."""
219
+ output_count = len(self.output) if self.output else 0
220
+ return f"ScheduleRunResult(id={self.id!r}, status={self.status!r}, output_chunks={output_count})"
221
+
222
+ def __str__(self) -> str:
223
+ """Return a readable string representation."""
224
+ return self.__repr__()
@@ -57,6 +57,7 @@ AGENT_FIELD_RULES: Mapping[str, FieldRule] = {
57
57
  "timeout": FieldRule(cli_managed_create=True, cli_managed_update=True),
58
58
  # Fields requiring sanitisation before sending to the API
59
59
  "agent_config": FieldRule(requires_sanitization=True),
60
+ "guardrail": FieldRule(requires_sanitization=True),
60
61
  }
61
62
 
62
63
 
@@ -0,0 +1,34 @@
1
+ """Guardrail payload schemas for API communication.
2
+
3
+ Authors:
4
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
5
+ """
6
+
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class GuardrailEnginePayload(BaseModel):
14
+ """Payload schema for a single guardrail engine configuration.
15
+
16
+ This model defines the structure for individual safety engines (e.g., phrase_matcher, nemo)
17
+ when communicating with the GL AIP backend.
18
+ """
19
+
20
+ type: str = Field(..., description="The type of guardrail engine (e.g., 'phrase_matcher', 'nemo')")
21
+ config: Mapping[str, Any] = Field(..., description="Engine-specific configuration parameters")
22
+
23
+
24
+ class GuardrailPayload(BaseModel):
25
+ """Payload schema for global guardrail settings.
26
+
27
+ This model acts as the container for all guardrail configurations within the agent_config.
28
+ """
29
+
30
+ enabled: bool = Field(default=True, description="Global toggle to enable or disable all guardrails")
31
+ engines: Sequence[GuardrailEnginePayload] = Field(
32
+ default_factory=list,
33
+ description="List of configured guardrail engines",
34
+ )
@@ -1,6 +1,6 @@
1
1
  """Tool registry for glaip_sdk.
2
2
 
3
- This module provides the ToolRegistry that caches deployed tools
3
+ This module provides a ToolRegistry that caches deployed tools
4
4
  to avoid redundant API calls when deploying agents with tools.
5
5
 
6
6
  Authors:
@@ -54,6 +54,32 @@ class ToolRegistry(BaseRegistry["Tool"]):
54
54
  value = getattr(obj, attr, None)
55
55
  return value if isinstance(value, str) else None
56
56
 
57
+ def _extract_name_from_instance(self, ref: Any) -> str | None:
58
+ """Extract name from a non-type instance.
59
+
60
+ Args:
61
+ ref: The instance to extract name from.
62
+
63
+ Returns:
64
+ The extracted name, or None if not found.
65
+ """
66
+ if isinstance(ref, type):
67
+ return None
68
+ return self._get_string_attr(ref, "name")
69
+
70
+ def _extract_name_from_class(self, ref: Any) -> str | None:
71
+ """Extract name from a class.
72
+
73
+ Args:
74
+ ref: The class to extract name from.
75
+
76
+ Returns:
77
+ The extracted name, or None if not found.
78
+ """
79
+ if not isinstance(ref, type):
80
+ return None
81
+ return self._get_string_attr(ref, "name") or self._get_name_from_model_fields(ref)
82
+
57
83
  def _extract_name(self, ref: Any) -> str:
58
84
  """Extract tool name from a reference.
59
85
 
@@ -66,90 +92,269 @@ class ToolRegistry(BaseRegistry["Tool"]):
66
92
  Raises:
67
93
  ValueError: If name cannot be extracted from the reference.
68
94
  """
95
+ # Lazy import to avoid circular dependency: Tool -> ToolRegistry -> Tool
96
+ from glaip_sdk.tools.base import Tool # noqa: PLC0415
97
+
69
98
  if isinstance(ref, str):
70
99
  return ref
71
100
 
101
+ # Tool instance (from Tool.from_langchain() or Tool.from_native())
102
+ if isinstance(ref, Tool):
103
+ return ref.get_name()
104
+
72
105
  # Dict from API response - extract name or id
73
106
  if isinstance(ref, dict):
74
107
  return ref.get("name") or ref.get("id") or ""
75
108
 
76
109
  # Tool instance (not a class) with name attribute
77
- if not isinstance(ref, type):
78
- name = self._get_string_attr(ref, "name")
79
- if name:
80
- return name
110
+ name = self._extract_name_from_instance(ref)
111
+ if name:
112
+ return name
81
113
 
82
114
  # Tool class - try direct attribute first, then model_fields
83
- if isinstance(ref, type):
84
- name = self._get_string_attr(ref, "name") or self._get_name_from_model_fields(ref)
85
- if name:
86
- return name
115
+ name = self._extract_name_from_class(ref)
116
+ if name:
117
+ return name
87
118
 
88
119
  raise ValueError(f"Cannot extract name from: {ref}")
89
120
 
90
- def _resolve_and_cache(self, ref: Any, name: str) -> Tool:
91
- """Resolve tool reference - upload if class, find if string/native.
121
+ def _cache_tool(self, tool: Tool, name: str) -> None:
122
+ """Cache a tool by name and ID if available.
92
123
 
93
124
  Args:
94
- ref: The tool reference to resolve.
95
- name: The extracted tool name.
125
+ tool: The tool to cache.
126
+ name: The tool name.
127
+ """
128
+ self._cache[name] = tool
129
+ if hasattr(tool, "id") and tool.id:
130
+ self._cache[tool.id] = tool
131
+
132
+ def _resolve_native_platform_tool(self, name: str, tool_class: type | None = None) -> Tool:
133
+ """Find a native tool on the platform and cache it.
134
+
135
+ Args:
136
+ name: The tool name to look up.
137
+ tool_class: Optional local implementation to preserve.
96
138
 
97
139
  Returns:
98
- The resolved glaip_sdk.models.Tool object.
140
+ The resolved Tool object.
99
141
 
100
142
  Raises:
101
- ValueError: If the tool cannot be resolved.
143
+ ValueError: If the tool is not found on the platform.
102
144
  """
103
- # Lazy imports to avoid circular dependency
104
145
  from glaip_sdk.utils.discovery import find_tool # noqa: PLC0415
146
+
147
+ logger.info("Looking up native tool: %s", name)
148
+ tool = find_tool(name)
149
+ if tool:
150
+ # Preserve local implementation if provided
151
+ if tool_class:
152
+ tool.tool_class = tool_class
153
+ self._cache_tool(tool, name)
154
+ return tool
155
+
156
+ raise ValueError(
157
+ f"Native tool '{name}' not found on platform. Ensure the tool is deployed or check for name mismatches."
158
+ )
159
+
160
+ def _resolve_tool_instance(self, ref: Any, name: str) -> Tool | None:
161
+ """Resolve a ToolClass instance.
162
+
163
+ Args:
164
+ ref: The ToolClass instance to resolve.
165
+ name: The extracted tool name.
166
+
167
+ Returns:
168
+ The resolved tool, or None if not a ToolClass instance.
169
+ """
170
+ # Lazy imports to avoid circular dependency: Tool -> ToolRegistry -> Tool
171
+ from glaip_sdk.tools.base import Tool as ToolClass # noqa: PLC0415
172
+ from glaip_sdk.tools.base import ToolType # noqa: PLC0415
173
+
174
+ # Use try/except to handle mocked Tool class in tests
175
+ try:
176
+ is_tool_instance = isinstance(ref, ToolClass)
177
+ except TypeError:
178
+ return None
179
+
180
+ if not is_tool_instance:
181
+ return None
182
+
183
+ # If Tool has an ID, it's already deployed - return as-is
184
+ if ref.id is not None:
185
+ logger.debug("Caching already deployed tool: %s", name)
186
+ self._cache_tool(ref, name)
187
+ return ref
188
+
189
+ # Tool.from_native() - look up on platform
190
+ if ref.tool_type == ToolType.NATIVE:
191
+ return self._resolve_native_platform_tool(name, tool_class=getattr(ref, "tool_class", None))
192
+
193
+ # Tool.from_langchain() - resolve the inner tool_class (promoted or uploaded)
194
+ if ref.tool_class is not None:
195
+ return self._resolve_custom_tool(ref.tool_class, name)
196
+
197
+ # Unresolvable Tool instance - neither native nor has tool_class
198
+ raise ValueError(
199
+ f"Cannot resolve Tool instance: {ref}. "
200
+ f"Tool has no id, is not NATIVE type, and has no tool_class. "
201
+ f"Ensure Tool is created via Tool.from_native() or Tool.from_langchain()."
202
+ )
203
+
204
+ def _resolve_deployed_tool(self, ref: Any, name: str) -> Tool | None:
205
+ """Resolve an already deployed tool (has id/name attributes).
206
+
207
+ Args:
208
+ ref: The tool reference to resolve.
209
+ name: The extracted tool name.
210
+
211
+ Returns:
212
+ The resolved tool, or None if not a deployed tool.
213
+ """
214
+ # Already deployed tool (not a ToolClass, but has id/name)
215
+ # This handles API response objects and backward compatibility
216
+ if not (hasattr(ref, "id") and hasattr(ref, "name") and not isinstance(ref, type)):
217
+ return None
218
+
219
+ if ref.id is not None:
220
+ logger.debug("Caching already deployed tool: %s", name)
221
+ # Use _cache_tool to cache by both name and ID for consistency
222
+ self._cache_tool(ref, name)
223
+ return ref
224
+
225
+ # Tool without ID (backward compatibility) - look up on platform
226
+ return self._resolve_native_platform_tool(name)
227
+
228
+ def _resolve_custom_tool(self, ref: Any, name: str) -> Tool | None:
229
+ """Resolve a custom tool class, promoting aip_agents.tools classes to NATIVE.
230
+
231
+ This method handles two main paths:
232
+ 1. **Promotion**: If the tool class is from `aip_agents.tools`, it is automatically
233
+ promoted to a `NATIVE` tool type. It then performs a platform lookup to link it
234
+ with the deployed native tool while preserving the local `tool_class` for local execution.
235
+ 2. **Upload**: If it is a standard LangChain tool, it is uploaded to the platform
236
+ as a custom tool.
237
+
238
+ Args:
239
+ ref: The tool reference (usually a class) to resolve.
240
+ name: The extracted tool name.
241
+
242
+ Returns:
243
+ The resolved tool, or None if not a custom tool.
244
+ """
245
+ # aip_agents tools are automatically promoted to NATIVE
246
+ if self._is_aip_agents_tool(ref):
247
+ from glaip_sdk.utils.tool_detection import get_tool_name # noqa: PLC0415
248
+
249
+ # Get name from class attribute or field
250
+ tool_name = get_tool_name(ref)
251
+ if tool_name is None:
252
+ raise ValueError(f"Tool class {ref.__name__} has no 'name' attribute")
253
+
254
+ return self._resolve_native_platform_tool(tool_name, tool_class=ref)
255
+
256
+ if not self._is_custom_tool(ref):
257
+ return None
258
+
259
+ # Regular custom tools - upload to platform
105
260
  from glaip_sdk.utils.sync import update_or_create_tool # noqa: PLC0415
106
261
 
107
- # Already deployed tool (glaip_sdk.models.Tool with ID) - just cache and return
108
- if hasattr(ref, "id") and hasattr(ref, "name") and not isinstance(ref, type):
109
- if ref.id is not None:
110
- logger.debug("Caching already deployed tool: %s", name)
111
- self._cache[name] = ref
112
- return ref
262
+ logger.info("Uploading custom tool: %s", name)
263
+ tool = update_or_create_tool(ref)
264
+
265
+ # Cache the resolved tool
266
+ self._cache_tool(tool, name)
267
+ if hasattr(tool, "id") and tool.id:
268
+ self._cache[tool.id] = tool
113
269
 
114
- # Tool without ID (e.g., Tool.from_native()) - look up on platform
115
- logger.info("Looking up native tool: %s", name)
116
- tool = find_tool(name)
117
- if tool:
118
- self._cache[name] = tool
119
- return tool
120
- raise ValueError(f"Native tool not found on platform: {name}")
121
-
122
- # Custom tool class - upload it
123
- if self._is_custom_tool(ref):
124
- logger.info("Uploading custom tool: %s", name)
125
- tool = update_or_create_tool(ref)
126
- self._cache[name] = tool
127
- if tool.id:
128
- self._cache[tool.id] = tool
270
+ return tool
271
+
272
+ def _resolve_dict_tool(self, ref: Any, name: str) -> Tool | None:
273
+ """Resolve a tool from a dict (API response).
274
+
275
+ Args:
276
+ ref: The dict to resolve.
277
+ name: The extracted tool name.
278
+
279
+ Returns:
280
+ The resolved tool, or None if not a dict.
281
+ """
282
+ # Lazy imports to avoid circular dependency
283
+ from glaip_sdk.tools.base import Tool as ToolClass # noqa: PLC0415
284
+
285
+ if not isinstance(ref, dict):
286
+ return None
287
+
288
+ tool_id = ref.get("id")
289
+ if tool_id:
290
+ tool = ToolClass(id=tool_id, name=ref.get("name", ""))
291
+ # Use _cache_tool to cache by both name and ID for consistency
292
+ self._cache_tool(tool, name)
129
293
  return tool
294
+ raise ValueError(f"Tool dict missing 'id': {ref}")
130
295
 
131
- # Dict from API response - use ID directly if available
132
- if isinstance(ref, dict):
133
- tool_id = ref.get("id")
134
- if tool_id:
135
- from glaip_sdk.tools.base import Tool # noqa: PLC0415
296
+ def _resolve_string_tool(self, ref: Any, name: str) -> Tool | None:
297
+ """Resolve a tool from a string name.
136
298
 
137
- tool = Tool(id=tool_id, name=ref.get("name", ""))
138
- self._cache[name] = tool
139
- return tool
140
- raise ValueError(f"Tool dict missing 'id': {ref}")
299
+ Args:
300
+ ref: The string to resolve.
301
+ name: The extracted tool name.
141
302
 
142
- # String name - look up on platform (could be native or existing tool)
143
- if isinstance(ref, str):
144
- logger.info("Looking up tool by name: %s", name)
145
- tool = find_tool(name)
146
- if tool:
147
- self._cache[name] = tool
148
- return tool
149
- raise ValueError(f"Tool not found on platform: {name}")
303
+ Returns:
304
+ The resolved tool, or None if not a string.
305
+ """
306
+ if not isinstance(ref, str):
307
+ return None
308
+
309
+ return self._resolve_native_platform_tool(name)
310
+
311
+ def _resolve_and_cache(self, ref: Any, name: str) -> Tool:
312
+ """Resolve tool reference - upload if class, find if string/native.
313
+
314
+ Args:
315
+ ref: The tool reference to resolve.
316
+ name: The extracted tool name.
317
+
318
+ Returns:
319
+ The resolved glaip_sdk.models.Tool object.
320
+
321
+ Raises:
322
+ ValueError: If tool cannot be resolved.
323
+ """
324
+ # Try each resolution strategy in order
325
+ resolvers = [
326
+ self._resolve_tool_instance,
327
+ self._resolve_deployed_tool,
328
+ self._resolve_custom_tool,
329
+ self._resolve_dict_tool,
330
+ self._resolve_string_tool,
331
+ ]
332
+
333
+ for resolver in resolvers:
334
+ result = resolver(ref, name)
335
+ if result is not None:
336
+ return result
150
337
 
151
338
  raise ValueError(f"Could not resolve tool reference: {ref}")
152
339
 
340
+ def _is_aip_agents_tool(self, ref: Any) -> bool:
341
+ """Check if reference is an aip-agents tool.
342
+
343
+ Args:
344
+ ref: The reference to check.
345
+
346
+ Returns:
347
+ True if ref is from aip_agents.tools package.
348
+ """
349
+ try:
350
+ from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
351
+ is_aip_agents_tool,
352
+ )
353
+ except ImportError:
354
+ return False
355
+
356
+ return is_aip_agents_tool(ref)
357
+
153
358
  def _is_custom_tool(self, ref: Any) -> bool:
154
359
  """Check if reference is a custom tool class/instance.
155
360
 
@@ -160,13 +365,22 @@ class ToolRegistry(BaseRegistry["Tool"]):
160
365
  True if ref is a custom tool that needs uploading.
161
366
  """
162
367
  try:
163
- from glaip_sdk.utils.tool_detection import is_langchain_tool # noqa: PLC0415
368
+ from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
369
+ is_langchain_tool,
370
+ )
164
371
 
165
- return is_langchain_tool(ref)
372
+ is_tool = is_langchain_tool(ref)
166
373
  except ImportError:
167
- # Handle case where langchain_core is not available or tool_detection import fails
374
+ is_tool = hasattr(ref, "args_schema") or hasattr(ref, "_run")
375
+ if is_tool:
376
+ logger.warning("tool_detection module missing; identifying tool via fallback attributes.")
377
+
378
+ # aip_agents tools are NOT custom - they're native
379
+ if is_tool and self._is_aip_agents_tool(ref):
168
380
  return False
169
381
 
382
+ return is_tool
383
+
170
384
  def resolve(self, ref: Any) -> Tool:
171
385
  """Resolve a tool reference to a platform Tool object.
172
386
 
@@ -184,9 +398,9 @@ class ToolRegistry(BaseRegistry["Tool"]):
184
398
  if ref.id is not None:
185
399
  name = self._extract_name(ref)
186
400
  if name not in self._cache:
187
- self._cache[name] = ref
401
+ # Use _cache_tool to cache by both name and ID for consistency
402
+ self._cache_tool(ref, name)
188
403
  return ref
189
-
190
404
  # Tool without ID (e.g., from Tool.from_native()) - needs platform lookup
191
405
  # Fall through to normal resolution
192
406