glaip-sdk 0.5.3__py3-none-any.whl → 0.6.0__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 (39) hide show
  1. glaip_sdk/__init__.py +4 -1
  2. glaip_sdk/agents/__init__.py +27 -0
  3. glaip_sdk/agents/base.py +989 -0
  4. glaip_sdk/cli/commands/accounts.py +210 -23
  5. glaip_sdk/cli/commands/tools.py +2 -5
  6. glaip_sdk/client/_agent_payloads.py +10 -9
  7. glaip_sdk/client/agents.py +70 -8
  8. glaip_sdk/client/base.py +1 -0
  9. glaip_sdk/client/main.py +12 -4
  10. glaip_sdk/client/mcps.py +112 -10
  11. glaip_sdk/client/tools.py +151 -7
  12. glaip_sdk/mcps/__init__.py +21 -0
  13. glaip_sdk/mcps/base.py +345 -0
  14. glaip_sdk/models/__init__.py +65 -31
  15. glaip_sdk/models/agent.py +47 -0
  16. glaip_sdk/models/agent_runs.py +0 -1
  17. glaip_sdk/models/common.py +42 -0
  18. glaip_sdk/models/mcp.py +33 -0
  19. glaip_sdk/models/tool.py +33 -0
  20. glaip_sdk/registry/__init__.py +55 -0
  21. glaip_sdk/registry/agent.py +164 -0
  22. glaip_sdk/registry/base.py +139 -0
  23. glaip_sdk/registry/mcp.py +251 -0
  24. glaip_sdk/registry/tool.py +238 -0
  25. glaip_sdk/tools/__init__.py +22 -0
  26. glaip_sdk/tools/base.py +435 -0
  27. glaip_sdk/utils/__init__.py +50 -9
  28. glaip_sdk/utils/bundler.py +267 -0
  29. glaip_sdk/utils/client.py +111 -0
  30. glaip_sdk/utils/client_utils.py +26 -7
  31. glaip_sdk/utils/discovery.py +78 -0
  32. glaip_sdk/utils/import_resolver.py +500 -0
  33. glaip_sdk/utils/instructions.py +101 -0
  34. glaip_sdk/utils/sync.py +142 -0
  35. {glaip_sdk-0.5.3.dist-info → glaip_sdk-0.6.0.dist-info}/METADATA +5 -3
  36. {glaip_sdk-0.5.3.dist-info → glaip_sdk-0.6.0.dist-info}/RECORD +38 -18
  37. glaip_sdk/models.py +0 -241
  38. {glaip_sdk-0.5.3.dist-info → glaip_sdk-0.6.0.dist-info}/WHEEL +0 -0
  39. {glaip_sdk-0.5.3.dist-info → glaip_sdk-0.6.0.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,9 @@ Authors:
6
6
 
7
7
  import getpass
8
8
  import json
9
+ import os
9
10
  import sys
11
+ from pathlib import Path
10
12
 
11
13
  import click
12
14
  from rich.console import Console
@@ -110,6 +112,153 @@ def list_accounts(output_json: bool) -> None:
110
112
  console.print(f"\n[{INFO}]💡 Tip[/]: To update an account's URL or key, use: [bold]aip accounts edit <name>[/bold]")
111
113
 
112
114
 
115
+ def _build_account_json_payload(
116
+ name: str,
117
+ api_url: str,
118
+ masked_key: str,
119
+ config_path: str,
120
+ is_active: bool,
121
+ env_lock: bool,
122
+ metadata: dict[str, str | None],
123
+ ) -> dict[str, str | bool | None]:
124
+ """Build JSON payload for account display.
125
+
126
+ Args:
127
+ name: Account name.
128
+ api_url: API URL.
129
+ masked_key: Masked API key.
130
+ config_path: Config file path.
131
+ is_active: Whether account is active.
132
+ env_lock: Whether env credentials are set.
133
+ metadata: Account metadata dict.
134
+
135
+ Returns:
136
+ JSON payload dict.
137
+ """
138
+ payload: dict[str, str | bool | None] = {
139
+ "name": name,
140
+ "api_url": api_url,
141
+ "api_key_masked": masked_key,
142
+ "config_path": config_path,
143
+ "active": is_active,
144
+ "env_lock": env_lock,
145
+ }
146
+ for key, value in metadata.items():
147
+ if value:
148
+ payload[key] = value
149
+ return payload
150
+
151
+
152
+ def _format_config_path(config_path: str) -> str:
153
+ """Format config path for display, shortening under home."""
154
+ path_obj = Path(config_path).expanduser()
155
+ try:
156
+ home = Path.home().expanduser()
157
+ resolved = path_obj.resolve(strict=False)
158
+ relative = resolved.relative_to(home).as_posix()
159
+ return f"~/{relative}"
160
+ except ValueError:
161
+ # Not under home; return expanded path
162
+ return str(path_obj)
163
+ except OSError:
164
+ # Fall back to original string on resolution errors
165
+ return config_path
166
+
167
+
168
+ def _build_account_display_lines(
169
+ name: str,
170
+ api_url: str,
171
+ masked_key: str,
172
+ config_path: str,
173
+ is_active: bool,
174
+ env_lock: bool,
175
+ metadata: dict[str, str | None],
176
+ ) -> list[str]:
177
+ """Build display lines for account information.
178
+
179
+ Args:
180
+ name: Account name.
181
+ api_url: API URL.
182
+ masked_key: Masked API key.
183
+ config_path: Config file path.
184
+ is_active: Whether account is active.
185
+ env_lock: Whether env credentials are set.
186
+ metadata: Account metadata dict.
187
+
188
+ Returns:
189
+ List of formatted display lines.
190
+ """
191
+ lines = [
192
+ f"[{SUCCESS_STYLE}]Name[/]: {name}{' (active)' if is_active else ''}",
193
+ f"[{SUCCESS_STYLE}]API URL[/]: {api_url or 'not set'}",
194
+ f"[{SUCCESS_STYLE}]Key[/]: {masked_key or 'not set'}",
195
+ f"[{SUCCESS_STYLE}]Config[/]: {config_path}",
196
+ ]
197
+
198
+ label_map = {
199
+ "notes": "Notes",
200
+ "last_used_at": "Last used",
201
+ "last_validated_at": "Last validated",
202
+ "created_with": "Created with",
203
+ }
204
+ for key, label in label_map.items():
205
+ value = metadata.get(key)
206
+ if value:
207
+ lines.append(f"[{SUCCESS_STYLE}]{label}[/]: {value}")
208
+
209
+ if env_lock:
210
+ lines.append(
211
+ f"[{WARNING_STYLE}]Env credentials detected (AIP_API_URL/AIP_API_KEY); stored profile may be ignored.[/]"
212
+ )
213
+
214
+ return lines
215
+
216
+
217
+ @accounts_group.command("show")
218
+ @click.argument("name")
219
+ @click.option("--json", "output_json", is_flag=True, help="Output in JSON format")
220
+ def show_account(name: str, output_json: bool) -> None:
221
+ """Show details for a single account profile."""
222
+ store = get_account_store()
223
+ account = store.get_account(name)
224
+
225
+ if not account:
226
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' not found.[/]")
227
+ raise click.Abort()
228
+
229
+ api_url = account.get("api_url", "")
230
+ api_key = account.get("api_key")
231
+ masked_key = _mask_api_key(api_key or "")
232
+ active_account = store.get_active_account()
233
+ is_active = active_account == name
234
+ env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
235
+ config_path_raw = str(store.config_file)
236
+ config_path_display = _format_config_path(config_path_raw)
237
+
238
+ metadata = {
239
+ "notes": account.get("notes"),
240
+ "last_used_at": account.get("last_used_at"),
241
+ "last_validated_at": account.get("last_validated_at"),
242
+ "created_with": account.get("created_with"),
243
+ }
244
+
245
+ if output_json:
246
+ payload = _build_account_json_payload(name, api_url, masked_key, config_path_raw, is_active, env_lock, metadata)
247
+ click.echo(json.dumps(payload, indent=2))
248
+ return
249
+
250
+ lines = _build_account_display_lines(name, api_url, masked_key, config_path_display, is_active, env_lock, metadata)
251
+
252
+ lock_badge = " 🔒 Env lock" if env_lock else ""
253
+ console.print(
254
+ AIPPanel(
255
+ "\n".join(lines),
256
+ title=f"AIP Account{lock_badge}",
257
+ border_style=ACCENT_STYLE,
258
+ ),
259
+ )
260
+
261
+
113
262
  def _check_account_overwrite(name: str, store: AccountStore, overwrite: bool) -> dict[str, str] | None:
114
263
  """Check if account exists and handle overwrite logic.
115
264
 
@@ -155,8 +304,8 @@ def _get_credentials_non_interactive(
155
304
  if not sys.stdin.isatty():
156
305
  return url, sys.stdin.read().strip()
157
306
  console.print(
158
- f"[{ERROR_STYLE}]Error: --key requires stdin input. "
159
- f"Use: cat key.txt | {command_name} {name} --url {url} --key[/]",
307
+ f"[{ERROR_STYLE}]Error: --key expects stdin or an explicit value. "
308
+ f"Use '--key <value>' or pipe: cat key.txt | {command_name} {name} --url {url} --key[/]",
160
309
  )
161
310
  raise click.Abort()
162
311
  # URL provided, prompt for key
@@ -179,7 +328,8 @@ def _get_credentials_interactive(read_key_from_stdin: bool, existing: dict[str,
179
328
  """
180
329
  if read_key_from_stdin:
181
330
  console.print(
182
- f"[{ERROR_STYLE}]Error: --key requires --url. For non-interactive mode, provide both: --url <url> --key[/]",
331
+ f"[{ERROR_STYLE}]Error: --key requires --url. "
332
+ f"Provide --url with --key <value|-> for non-interactive use or omit --key to be prompted.[/]",
183
333
  )
184
334
  raise click.Abort()
185
335
  # Fully interactive
@@ -237,7 +387,7 @@ def _preserve_existing_values(
237
387
 
238
388
  def _collect_credentials_from_inputs(
239
389
  url: str | None,
240
- read_key_from_stdin: bool,
390
+ api_key_input: str | None,
241
391
  name: str,
242
392
  existing: dict[str, str] | None,
243
393
  command_name: str,
@@ -247,7 +397,7 @@ def _collect_credentials_from_inputs(
247
397
 
248
398
  Args:
249
399
  url: Optional URL from flag.
250
- read_key_from_stdin: Whether to read key from stdin.
400
+ api_key_input: API key value from flag (or "-" when stdin requested).
251
401
  name: Account name (for error messages).
252
402
  existing: Existing account data.
253
403
  command_name: Command name for error messages.
@@ -256,6 +406,29 @@ def _collect_credentials_from_inputs(
256
406
  Returns:
257
407
  Tuple of (api_url, api_key).
258
408
  """
409
+ provided_key = api_key_input if api_key_input not in (None, "-") else None
410
+ read_key_from_stdin = api_key_input == "-"
411
+
412
+ if provided_key and url:
413
+ # Fully non-interactive: URL and key provided via flags
414
+ return url, provided_key
415
+
416
+ if provided_key:
417
+ # Reuse stored URL if present; otherwise require --url
418
+ if existing_url:
419
+ return existing_url, provided_key
420
+ if existing:
421
+ console.print(
422
+ f"[{ERROR_STYLE}]Error: Account '{name}' is missing an API URL. "
423
+ f"Provide --url to set it when rotating the key.[/]"
424
+ )
425
+ else:
426
+ console.print(
427
+ f"[{ERROR_STYLE}]Error: --key requires --url for new accounts. "
428
+ f"Run without --key for prompts or pass both flags for non-interactive setup.[/]",
429
+ )
430
+ raise click.Abort()
431
+
259
432
  if url and read_key_from_stdin:
260
433
  # Non-interactive: URL from flag, key from stdin
261
434
  return _get_credentials_non_interactive(url, True, name, command_name)
@@ -271,15 +444,25 @@ def _collect_credentials_from_inputs(
271
444
 
272
445
  def _collect_account_credentials(
273
446
  url: str | None,
274
- read_key_from_stdin: bool,
447
+ api_key_input: str | None,
275
448
  name: str,
276
449
  existing: dict[str, str] | None,
277
450
  ) -> tuple[str, str]:
278
451
  """Collect account credentials from various input methods.
279
452
 
453
+ Examples:
454
+ # Inline key
455
+ aip accounts add prod --url https://api.example.com --key sk-abc123
456
+
457
+ # Stdin (useful for scripts)
458
+ echo "sk-abc123" | aip accounts add prod --url https://api.example.com --key
459
+
460
+ # Fully interactive
461
+ aip accounts add prod
462
+
280
463
  Args:
281
464
  url: Optional URL from flag.
282
- read_key_from_stdin: Whether to read key from stdin.
465
+ api_key_input: API key value from flag (or "-" when stdin requested).
283
466
  name: Account name (for error messages).
284
467
  existing: Existing account data.
285
468
 
@@ -293,9 +476,7 @@ def _collect_account_credentials(
293
476
  existing_url = existing.get("api_url", "") if existing else ""
294
477
  existing_key = existing.get("api_key", "") if existing else ""
295
478
 
296
- api_url, api_key = _collect_credentials_from_inputs(
297
- url, read_key_from_stdin, name, existing, command_name, existing_url
298
- )
479
+ api_url, api_key = _collect_credentials_from_inputs(url, api_key_input, name, existing, command_name, existing_url)
299
480
 
300
481
  # Preserve stored values when blank input is provided during edit
301
482
  api_url, api_key = _preserve_existing_values(api_url, api_key, existing_url, existing_key)
@@ -311,9 +492,12 @@ def _collect_account_credentials(
311
492
  @click.option("--url", help="API URL (required for non-interactive mode)")
312
493
  @click.option(
313
494
  "--key",
314
- "read_key_from_stdin",
315
- is_flag=True,
316
- help="Read API key from stdin (secure, for scripts). Requires --url.",
495
+ "api_key_input",
496
+ type=str,
497
+ is_flag=False,
498
+ flag_value="-",
499
+ default=None,
500
+ help="API key value. Pass without a value or '-' to read from stdin. Requires --url for non-interactive use.",
317
501
  )
318
502
  @click.option(
319
503
  "--yes",
@@ -324,7 +508,7 @@ def _collect_account_credentials(
324
508
  def add_account(
325
509
  name: str,
326
510
  url: str | None,
327
- read_key_from_stdin: bool,
511
+ api_key_input: str | None,
328
512
  overwrite: bool,
329
513
  ) -> None:
330
514
  """Add a new account profile.
@@ -332,7 +516,7 @@ def add_account(
332
516
  NAME is the account name (1-32 chars, alphanumeric, dash, underscore).
333
517
 
334
518
  By default, this command runs interactively, prompting for API URL and key.
335
- For non-interactive use, both --url and --key (stdin) are required.
519
+ For non-interactive use, provide --url with --key <value> or --key - (stdin).
336
520
 
337
521
  If the account already exists, use --yes to overwrite without prompting.
338
522
  To update an existing account, use [bold]aip accounts edit <name>[/bold] instead.
@@ -343,7 +527,7 @@ def add_account(
343
527
  existing = _check_account_overwrite(name, store, overwrite)
344
528
 
345
529
  # Collect credentials
346
- api_url, api_key = _collect_account_credentials(url, read_key_from_stdin, name, existing)
530
+ api_url, api_key = _collect_account_credentials(url, api_key_input, name, existing)
347
531
 
348
532
  # Save account
349
533
  try:
@@ -363,14 +547,17 @@ def add_account(
363
547
  @click.option("--url", help="API URL (optional, leave blank to keep current)")
364
548
  @click.option(
365
549
  "--key",
366
- "read_key_from_stdin",
367
- is_flag=True,
368
- help="Read API key from stdin (secure, for scripts). Uses stored URL unless --url is provided.",
550
+ "api_key_input",
551
+ type=str,
552
+ is_flag=False,
553
+ flag_value="-",
554
+ default=None,
555
+ help="API key value. Pass without a value or '-' to read from stdin. Uses stored URL unless --url is provided.",
369
556
  )
370
557
  def edit_account(
371
558
  name: str,
372
559
  url: str | None,
373
- read_key_from_stdin: bool,
560
+ api_key_input: str | None,
374
561
  ) -> None:
375
562
  """Edit an existing account profile's URL or key.
376
563
 
@@ -379,8 +566,8 @@ def edit_account(
379
566
  By default, this command runs interactively, showing current values and
380
567
  prompting for new ones. Leave fields blank to keep current values.
381
568
 
382
- For non-interactive use, provide --url to change the URL, --key (stdin) to rotate the key,
383
- or both. Stored values are reused for any fields not provided.
569
+ For non-interactive use, provide --url to change the URL, --key <value> to rotate the key,
570
+ or --key - (stdin) for scripts. Stored values are reused for any fields not provided.
384
571
  """
385
572
  store = get_account_store()
386
573
 
@@ -392,7 +579,7 @@ def edit_account(
392
579
  raise click.Abort()
393
580
 
394
581
  # Collect credentials (will pre-fill existing values in interactive mode)
395
- api_url, api_key = _collect_account_credentials(url, read_key_from_stdin, name, existing)
582
+ api_url, api_key = _collect_account_credentials(url, api_key_input, name, existing)
396
583
 
397
584
  # Save account
398
585
  try:
@@ -10,8 +10,6 @@ from pathlib import Path
10
10
  from typing import Any
11
11
 
12
12
  import click
13
- from rich.console import Console
14
-
15
13
  from glaip_sdk.branding import (
16
14
  ACCENT_STYLE,
17
15
  ERROR_STYLE,
@@ -29,9 +27,7 @@ from glaip_sdk.cli.display import (
29
27
  handle_json_output,
30
28
  handle_rich_output,
31
29
  )
32
- from glaip_sdk.cli.io import (
33
- fetch_raw_resource_details,
34
- )
30
+ from glaip_sdk.cli.io import fetch_raw_resource_details
35
31
  from glaip_sdk.cli.io import (
36
32
  load_resource_from_file_with_validation as load_resource_from_file,
37
33
  )
@@ -49,6 +45,7 @@ from glaip_sdk.cli.utils import (
49
45
  )
50
46
  from glaip_sdk.icons import ICON_TOOL
51
47
  from glaip_sdk.utils.import_export import merge_import_with_cli_args
48
+ from rich.console import Console
52
49
 
53
50
  console = Console()
54
51
 
@@ -15,10 +15,7 @@ from glaip_sdk.config.constants import (
15
15
  DEFAULT_AGENT_VERSION,
16
16
  DEFAULT_MODEL,
17
17
  )
18
- from glaip_sdk.payload_schemas.agent import (
19
- AgentImportOperation,
20
- get_import_field_plan,
21
- )
18
+ from glaip_sdk.payload_schemas.agent import AgentImportOperation, get_import_field_plan
22
19
  from glaip_sdk.utils.client_utils import extract_ids
23
20
 
24
21
  _LM_CONFLICT_KEYS = {
@@ -437,12 +434,16 @@ __all__ = [
437
434
 
438
435
  def _build_base_update_payload(request: AgentUpdateRequest, current_agent: Any) -> dict[str, Any]:
439
436
  """Populate immutable agent update fields using request data or existing agent defaults."""
437
+ # Support both "agent_type" (runtime class) and "type" (API response) attributes
438
+ current_type = getattr(current_agent, "agent_type", None) or getattr(current_agent, "type", None)
440
439
  return {
441
- "name": request.name.strip() if request.name is not None else getattr(current_agent, "name", None),
442
- "instruction": request.instruction.strip()
443
- if request.instruction is not None
444
- else getattr(current_agent, "instruction", None),
445
- "type": request.agent_type or getattr(current_agent, "type", None) or DEFAULT_AGENT_TYPE,
440
+ "name": (request.name.strip() if request.name is not None else getattr(current_agent, "name", None)),
441
+ "instruction": (
442
+ request.instruction.strip()
443
+ if request.instruction is not None
444
+ else getattr(current_agent, "instruction", None)
445
+ ),
446
+ "type": request.agent_type or current_type or DEFAULT_AGENT_TYPE,
446
447
  "framework": request.framework or getattr(current_agent, "framework", None) or DEFAULT_AGENT_FRAMEWORK,
447
448
  "version": request.version or getattr(current_agent, "version", None) or DEFAULT_AGENT_VERSION,
448
449
  }
@@ -3,6 +3,7 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
8
9
  import asyncio
@@ -15,7 +16,7 @@ from pathlib import Path
15
16
  from typing import Any, BinaryIO
16
17
 
17
18
  import httpx
18
-
19
+ from glaip_sdk.agents import Agent
19
20
  from glaip_sdk.client._agent_payloads import (
20
21
  AgentCreateRequest,
21
22
  AgentListParams,
@@ -40,7 +41,7 @@ from glaip_sdk.config.constants import (
40
41
  DEFAULT_MODEL,
41
42
  )
42
43
  from glaip_sdk.exceptions import NotFoundError, ValidationError
43
- from glaip_sdk.models import Agent
44
+ from glaip_sdk.models import AgentResponse
44
45
  from glaip_sdk.payload_schemas.agent import list_server_only_fields
45
46
  from glaip_sdk.utils.agent_config import normalize_agent_config_for_import
46
47
  from glaip_sdk.utils.client_utils import (
@@ -73,7 +74,9 @@ _DEFAULT_METADATA_TYPE = "custom"
73
74
 
74
75
 
75
76
  @asynccontextmanager
76
- async def _async_timeout_guard(timeout_seconds: float | None) -> AsyncGenerator[None, None]:
77
+ async def _async_timeout_guard(
78
+ timeout_seconds: float | None,
79
+ ) -> AsyncGenerator[None, None]:
77
80
  """Apply an asyncio timeout when a custom timeout is provided."""
78
81
  if timeout_seconds is None:
79
82
  yield
@@ -260,7 +263,7 @@ class AgentClient(BaseClient):
260
263
  self._renderer_manager = AgentRunRenderingManager(logger)
261
264
  self._tool_client: ToolClient | None = None
262
265
  self._mcp_client: MCPClient | None = None
263
- self._runs_client: "AgentRunsClient | None" = None
266
+ self._runs_client: AgentRunsClient | None = None
264
267
 
265
268
  def list_agents(
266
269
  self,
@@ -359,7 +362,8 @@ class AgentClient(BaseClient):
359
362
  status_code=404,
360
363
  )
361
364
 
362
- return Agent(**data)._set_client(self)
365
+ response = AgentResponse(**data)
366
+ return Agent.from_response(response, client=self)
363
367
 
364
368
  def find_agents(self, name: str | None = None) -> list[Agent]:
365
369
  """Find agents by name."""
@@ -826,7 +830,8 @@ class AgentClient(BaseClient):
826
830
  get_endpoint_fmt=f"{AGENTS_ENDPOINT}{{id}}",
827
831
  json=payload_dict,
828
832
  )
829
- return Agent(**full_agent_data)._set_client(self)
833
+ response = AgentResponse(**full_agent_data)
834
+ return Agent.from_response(response, client=self)
830
835
 
831
836
  def create_agent(
832
837
  self,
@@ -921,8 +926,9 @@ class AgentClient(BaseClient):
921
926
 
922
927
  payload_dict = request.to_payload(current_agent)
923
928
 
924
- response = self._request("PUT", f"/agents/{agent_id}", json=payload_dict)
925
- return Agent(**response)._set_client(self)
929
+ api_response = self._request("PUT", f"/agents/{agent_id}", json=payload_dict)
930
+ response = AgentResponse(**api_response)
931
+ return Agent.from_response(response, client=self)
926
932
 
927
933
  def update_agent(
928
934
  self,
@@ -969,6 +975,62 @@ class AgentClient(BaseClient):
969
975
  """Delete an agent."""
970
976
  self._request("DELETE", f"/agents/{agent_id}")
971
977
 
978
+ def upsert_agent(self, identifier: str | Agent, **kwargs) -> Agent:
979
+ """Create or update an agent by instance, ID, or name.
980
+
981
+ Args:
982
+ identifier: Agent instance, ID (UUID string), or name
983
+ **kwargs: Agent configuration (instruction, description, tools, etc.)
984
+
985
+ Returns:
986
+ The created or updated agent.
987
+
988
+ Example:
989
+ >>> # By name (creates if not exists)
990
+ >>> agent = client.agents.upsert_agent(
991
+ ... "hello_agent",
992
+ ... instruction="You are a helpful assistant.",
993
+ ... description="A friendly agent",
994
+ ... )
995
+ >>> # By instance
996
+ >>> agent = client.agents.upsert_agent(existing_agent, description="Updated")
997
+ >>> # By ID
998
+ >>> agent = client.agents.upsert_agent("uuid-here", description="Updated")
999
+ """
1000
+ # Handle Agent instance
1001
+ if isinstance(identifier, Agent):
1002
+ if identifier.id:
1003
+ logger.info("Updating agent by instance: %s", identifier.name)
1004
+ return self.update_agent(identifier.id, name=identifier.name, **kwargs)
1005
+ identifier = identifier.name
1006
+
1007
+ # Handle string (ID or name)
1008
+ if isinstance(identifier, str):
1009
+ # Check if it's a UUID
1010
+ if is_uuid(identifier):
1011
+ logger.info("Updating agent by ID: %s", identifier)
1012
+ return self.update_agent(identifier, **kwargs)
1013
+
1014
+ # It's a name - find or create
1015
+ return self._upsert_agent_by_name(identifier, **kwargs)
1016
+
1017
+ raise ValueError(f"Invalid identifier type: {type(identifier)}")
1018
+
1019
+ def _upsert_agent_by_name(self, name: str, **kwargs) -> Agent:
1020
+ """Find agent by name and update, or create if not found."""
1021
+ existing = self.find_agents(name)
1022
+
1023
+ if len(existing) == 1:
1024
+ logger.info("Updating existing agent: %s", name)
1025
+ return self.update_agent(existing[0].id, name=name, **kwargs)
1026
+
1027
+ if len(existing) > 1:
1028
+ raise ValueError(f"Multiple agents found with name '{name}'")
1029
+
1030
+ # Create new agent
1031
+ logger.info("Creating new agent: %s", name)
1032
+ return self.create_agent(name=name, **kwargs)
1033
+
972
1034
  def _prepare_sync_request_data(
973
1035
  self,
974
1036
  message: str,
glaip_sdk/client/base.py CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
8
9
  import logging
glaip_sdk/client/main.py CHANGED
@@ -3,16 +3,24 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
8
- from typing import Any
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
9
12
 
10
13
  from glaip_sdk.client.agents import AgentClient
11
14
  from glaip_sdk.client.base import BaseClient
12
15
  from glaip_sdk.client.mcps import MCPClient
13
16
  from glaip_sdk.client.shared import build_shared_config
14
17
  from glaip_sdk.client.tools import ToolClient
15
- from glaip_sdk.models import MCP, Agent, Tool
18
+
19
+ if TYPE_CHECKING:
20
+ from glaip_sdk.agents import Agent
21
+ from glaip_sdk.client._agent_payloads import AgentListResult
22
+ from glaip_sdk.mcps import MCP
23
+ from glaip_sdk.tools import Tool
16
24
 
17
25
 
18
26
  class Client(BaseClient):
@@ -49,7 +57,7 @@ class Client(BaseClient):
49
57
  name: str | None = None,
50
58
  version: str | None = None,
51
59
  sync_langflow_agents: bool = False,
52
- ) -> list[Agent]:
60
+ ) -> AgentListResult:
53
61
  """List agents with optional filtering.
54
62
 
55
63
  Args:
@@ -60,7 +68,7 @@ class Client(BaseClient):
60
68
  sync_langflow_agents: Sync with LangFlow server before listing (only applies when agent_type=langflow)
61
69
 
62
70
  Returns:
63
- List of agents matching the filters
71
+ AgentListResult with agents and pagination metadata. Supports iteration and indexing.
64
72
  """
65
73
  return self.agents.list_agents(
66
74
  agent_type=agent_type,