glaip-sdk 0.0.7__py3-none-any.whl → 0.0.9__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.
@@ -0,0 +1,665 @@
1
+ """SlashSession orchestrates the interactive command palette.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shlex
11
+ import sys
12
+ from collections.abc import Callable, Iterable
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from glaip_sdk.branding import AIPBranding
21
+ from glaip_sdk.cli.commands.configure import configure_command, load_config
22
+ from glaip_sdk.cli.utils import _fuzzy_pick_for_resources, get_client
23
+ from glaip_sdk.rich_components import AIPPanel
24
+
25
+ from .agent_session import AgentRunSession
26
+ from .prompt import (
27
+ FormattedText,
28
+ PromptSession,
29
+ Style,
30
+ patch_stdout,
31
+ setup_prompt_toolkit,
32
+ )
33
+
34
+ SlashHandler = Callable[["SlashSession", list[str], bool], bool]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class SlashCommand:
39
+ """Metadata for a slash command entry."""
40
+
41
+ name: str
42
+ help: str
43
+ handler: SlashHandler
44
+ aliases: tuple[str, ...] = ()
45
+
46
+
47
+ class SlashSession:
48
+ """Interactive command palette controller."""
49
+
50
+ def __init__(self, ctx: click.Context, *, console: Console | None = None) -> None:
51
+ self.ctx = ctx
52
+ self.console = console or Console()
53
+ self._commands: dict[str, SlashCommand] = {}
54
+ self._unique_commands: dict[str, SlashCommand] = {}
55
+ self._contextual_commands: dict[str, str] = {}
56
+ self._contextual_include_global: bool = True
57
+ self._client: Any | None = None
58
+ self.recent_agents: list[dict[str, str]] = []
59
+ self.last_run_input: str | None = None
60
+ self._should_exit = False
61
+ self._interactive = bool(sys.stdin.isatty() and sys.stdout.isatty())
62
+ self._config_cache: dict[str, Any] | None = None
63
+ self._welcome_rendered = False
64
+
65
+ self._home_placeholder = "Start with / to browse commands"
66
+
67
+ # Command string constants to avoid duplication
68
+ self.STATUS_COMMAND = "/status"
69
+ self.AGENTS_COMMAND = "/agents"
70
+
71
+ self._ptk_session: PromptSession | None = None
72
+ self._ptk_style: Style | None = None
73
+ self._setup_prompt_toolkit()
74
+ self._register_defaults()
75
+ self._branding = AIPBranding.create_from_sdk()
76
+
77
+ # ------------------------------------------------------------------
78
+ # Session orchestration
79
+ # ------------------------------------------------------------------
80
+
81
+ def _setup_prompt_toolkit(self) -> None:
82
+ session, style = setup_prompt_toolkit(self, interactive=self._interactive)
83
+ self._ptk_session = session
84
+ self._ptk_style = style
85
+
86
+ def run(self, initial_commands: Iterable[str] | None = None) -> None:
87
+ """Start the command palette session loop."""
88
+
89
+ if not self._interactive:
90
+ self._run_non_interactive(initial_commands)
91
+ return
92
+
93
+ if not self._ensure_configuration():
94
+ return
95
+
96
+ self._render_header(initial=True)
97
+ self._render_home_hint()
98
+ self._run_interactive_loop()
99
+
100
+ def _run_interactive_loop(self) -> None:
101
+ """Run the main interactive command loop."""
102
+ while not self._should_exit:
103
+ try:
104
+ raw = self._prompt("› ", placeholder=self._home_placeholder)
105
+ except EOFError:
106
+ self.console.print("\n👋 Closing the command palette.")
107
+ break
108
+ except KeyboardInterrupt:
109
+ self.console.print("")
110
+ continue
111
+
112
+ if not self._process_command(raw):
113
+ break
114
+
115
+ def _process_command(self, raw: str) -> bool:
116
+ """Process a single command input. Returns False if should exit."""
117
+ raw = raw.strip()
118
+ if not raw:
119
+ return True
120
+
121
+ if raw == "/":
122
+ self._cmd_help([], invoked_from_agent=False)
123
+ return True
124
+
125
+ if not raw.startswith("/"):
126
+ self.console.print(
127
+ "[yellow]Hint:[/] start commands with `/`. Try `/agents` to select an agent."
128
+ )
129
+ return True
130
+
131
+ return self.handle_command(raw)
132
+
133
+ def _run_non_interactive(
134
+ self, initial_commands: Iterable[str] | None = None
135
+ ) -> None:
136
+ """Run slash commands in non-interactive mode."""
137
+ commands = list(initial_commands or [])
138
+ if not commands:
139
+ commands = [line.strip() for line in sys.stdin if line.strip()]
140
+
141
+ for raw in commands:
142
+ if not raw.startswith("/"):
143
+ continue
144
+ if not self.handle_command(raw):
145
+ break
146
+
147
+ def _ensure_configuration(self) -> bool:
148
+ """Ensure the CLI has both API URL and credentials before continuing."""
149
+
150
+ while not self._configuration_ready():
151
+ self.console.print(
152
+ "[yellow]Configuration required.[/] Launching `/login` wizard..."
153
+ )
154
+ try:
155
+ self._cmd_login([], False)
156
+ except KeyboardInterrupt:
157
+ self.console.print(
158
+ "[red]Configuration aborted. Closing the command palette.[/red]"
159
+ )
160
+ return False
161
+
162
+ return True
163
+
164
+ def _configuration_ready(self) -> bool:
165
+ """Check whether API URL and credentials are available."""
166
+
167
+ config = self._load_config()
168
+ api_url = self._get_api_url(config)
169
+ if not api_url:
170
+ return False
171
+
172
+ api_key: str | None = None
173
+ if isinstance(self.ctx.obj, dict):
174
+ api_key = self.ctx.obj.get("api_key")
175
+
176
+ api_key = api_key or config.get("api_key") or os.getenv("AIP_API_KEY")
177
+ return bool(api_key)
178
+
179
+ def handle_command(self, raw: str, *, invoked_from_agent: bool = False) -> bool:
180
+ """Parse and execute a single slash command string."""
181
+
182
+ verb, args = self._parse(raw)
183
+ if not verb:
184
+ self.console.print("[red]Unrecognised command[/red]")
185
+ return True
186
+
187
+ command = self._commands.get(verb)
188
+ if command is None:
189
+ suggestion = self._suggest(verb)
190
+ if suggestion:
191
+ self.console.print(
192
+ f"[yellow]Unknown command '{verb}'. Did you mean '/{suggestion}'?[/yellow]"
193
+ )
194
+ else:
195
+ self.console.print(
196
+ "[yellow]Unknown command '{verb}'. Type `/help` for a list of options.[/yellow]"
197
+ )
198
+ return True
199
+
200
+ should_continue = command.handler(self, args, invoked_from_agent)
201
+ if not should_continue:
202
+ self._should_exit = True
203
+ return False
204
+ return True
205
+
206
+ # ------------------------------------------------------------------
207
+ # Command handlers
208
+ # ------------------------------------------------------------------
209
+ def _cmd_help(self, _args: list[str], invoked_from_agent: bool) -> bool:
210
+ try:
211
+ if invoked_from_agent:
212
+ table = Table(title="Agent Context")
213
+ table.add_column("Input", style="cyan", no_wrap=True)
214
+ table.add_column("What happens", style="green")
215
+ table.add_row(
216
+ "<message>", "Run the active agent once with that prompt."
217
+ )
218
+ table.add_row("/details", "Show the full agent export and metadata.")
219
+ table.add_row(
220
+ self.STATUS_COMMAND, "Display connection status without leaving."
221
+ )
222
+ table.add_row("/exit (/back)", "Return to the slash home screen.")
223
+ table.add_row("/help (/?)", "Display this context-aware menu.")
224
+ self.console.print(table)
225
+ if self.last_run_input:
226
+ self.console.print(f"[dim]Last run input:[/] {self.last_run_input}")
227
+ self.console.print(
228
+ "[dim]Global commands (e.g. `/login`, `/status`) remain available inside the agent prompt.[/dim]"
229
+ )
230
+ else:
231
+ table = Table(title="Slash Commands")
232
+ table.add_column("Command", style="cyan", no_wrap=True)
233
+ table.add_column("Description", style="green")
234
+
235
+ for cmd in sorted(self._unique_commands.values(), key=lambda c: c.name):
236
+ aliases = ", ".join(f"/{alias}" for alias in cmd.aliases if alias)
237
+ verb = f"/{cmd.name}"
238
+ if aliases:
239
+ verb = f"{verb} ({aliases})"
240
+ table.add_row(verb, cmd.help)
241
+
242
+ self.console.print(table)
243
+ self.console.print(
244
+ "[dim]Tip: `{self.AGENTS_COMMAND}` lets you jump into an agent run prompt quickly.[/dim]"
245
+ )
246
+ except Exception as exc: # pragma: no cover - UI/display errors
247
+ self.console.print(f"[red]Error displaying help: {exc}[/red]")
248
+ return False
249
+
250
+ return True
251
+
252
+ def _cmd_login(self, _args: list[str], _invoked_from_agent: bool) -> bool:
253
+ self.console.print("[cyan]Launching configuration wizard...[/cyan]")
254
+ try:
255
+ self.ctx.invoke(configure_command)
256
+ self._config_cache = None
257
+ self._render_header(initial=True)
258
+ self._show_quick_actions(
259
+ [
260
+ (self.STATUS_COMMAND, "Verify the connection"),
261
+ (self.AGENTS_COMMAND, "Pick an agent to inspect or run"),
262
+ ]
263
+ )
264
+ except click.ClickException as exc:
265
+ self.console.print(f"[red]{exc}[/red]")
266
+ return True
267
+
268
+ def _cmd_status(self, _args: list[str], _invoked_from_agent: bool) -> bool:
269
+ try:
270
+ from glaip_sdk.cli.main import status as status_command
271
+
272
+ self.ctx.invoke(status_command)
273
+ hints: list[tuple[str, str]] = [
274
+ (self.AGENTS_COMMAND, "Browse agents and run them")
275
+ ]
276
+ if self.recent_agents:
277
+ top = self.recent_agents[0]
278
+ label = top.get("name") or top.get("id")
279
+ hints.append((f"/agents {top.get('id')}", f"Reopen {label}"))
280
+ self._show_quick_actions(hints, title="Next actions")
281
+ except click.ClickException as exc:
282
+ self.console.print(f"[red]{exc}[/red]")
283
+ return True
284
+
285
+ def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
286
+ try:
287
+ client = self._get_client()
288
+ except click.ClickException as exc:
289
+ self.console.print(f"[red]{exc}[/red]")
290
+ return True
291
+
292
+ try:
293
+ agents = client.list_agents()
294
+ except Exception as exc: # pragma: no cover - API failures
295
+ self.console.print(f"[red]Failed to load agents: {exc}[/red]")
296
+ return True
297
+
298
+ if not agents:
299
+ self.console.print(
300
+ "[yellow]No agents available. Use `aip agents create` to add one.[/yellow]"
301
+ )
302
+ return True
303
+
304
+ if args:
305
+ picked_agent = self._resolve_agent_from_ref(client, agents, args[0])
306
+ if picked_agent is None:
307
+ self.console.print(
308
+ f"[yellow]Could not resolve agent '{args[0]}'. Try `/agents` to browse interactively.[/yellow]"
309
+ )
310
+ return True
311
+ else:
312
+ picked_agent = _fuzzy_pick_for_resources(agents, "agent", "")
313
+
314
+ if not picked_agent:
315
+ return True
316
+
317
+ self._remember_agent(picked_agent)
318
+
319
+ AgentRunSession(self, picked_agent).run()
320
+
321
+ # Refresh the main palette header and surface follow-up actions so the
322
+ # user has immediate cues after leaving the agent context.
323
+ self._render_header()
324
+
325
+ agent_id = str(getattr(picked_agent, "id", ""))
326
+ agent_label = getattr(picked_agent, "name", "") or agent_id or "this agent"
327
+ hints: list[tuple[str, str]] = []
328
+ if agent_id:
329
+ hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
330
+ hints.extend(
331
+ [
332
+ (self.AGENTS_COMMAND, "Browse agents"),
333
+ (self.STATUS_COMMAND, "Check connection"),
334
+ ]
335
+ )
336
+ self._show_quick_actions(hints, title="Next actions")
337
+ return True
338
+
339
+ def _cmd_exit(self, _args: list[str], invoked_from_agent: bool) -> bool:
340
+ if invoked_from_agent:
341
+ # Returning False would stop the full session; we only want to exit
342
+ # the agent context. Raising a custom flag keeps the outer loop
343
+ # running.
344
+ return True
345
+
346
+ self.console.print("[cyan]Closing the command palette.")
347
+ return False
348
+
349
+ # ------------------------------------------------------------------
350
+ # Utilities
351
+ # ------------------------------------------------------------------
352
+ def _register_defaults(self) -> None:
353
+ self._register(
354
+ SlashCommand(
355
+ name="help",
356
+ help="Show available command palette commands.",
357
+ handler=SlashSession._cmd_help,
358
+ aliases=("?",),
359
+ )
360
+ )
361
+ self._register(
362
+ SlashCommand(
363
+ name="login",
364
+ help="Run `aip configure` to set credentials.",
365
+ handler=SlashSession._cmd_login,
366
+ aliases=("configure",),
367
+ )
368
+ )
369
+ self._register(
370
+ SlashCommand(
371
+ name="status",
372
+ help="Display connection status summary.",
373
+ handler=SlashSession._cmd_status,
374
+ )
375
+ )
376
+ self._register(
377
+ SlashCommand(
378
+ name="agents",
379
+ help="Pick an agent and enter a focused run prompt.",
380
+ handler=SlashSession._cmd_agents,
381
+ )
382
+ )
383
+ self._register(
384
+ SlashCommand(
385
+ name="exit",
386
+ help="Exit the command palette.",
387
+ handler=SlashSession._cmd_exit,
388
+ aliases=("q",),
389
+ )
390
+ )
391
+
392
+ def _register(self, command: SlashCommand) -> None:
393
+ self._unique_commands[command.name] = command
394
+ for key in (command.name, *command.aliases):
395
+ self._commands[key] = command
396
+
397
+ def _parse(self, raw: str) -> tuple[str, list[str]]:
398
+ try:
399
+ tokens = shlex.split(raw)
400
+ except ValueError:
401
+ return "", []
402
+
403
+ if not tokens:
404
+ return "", []
405
+
406
+ head = tokens[0]
407
+ if head.startswith("/"):
408
+ head = head[1:]
409
+
410
+ return head, tokens[1:]
411
+
412
+ def _suggest(self, verb: str) -> str | None:
413
+ from difflib import get_close_matches
414
+
415
+ keys = [cmd.name for cmd in self._unique_commands.values()]
416
+ match = get_close_matches(verb, keys, n=1)
417
+ return match[0] if match else None
418
+
419
+ def _prompt(self, message: str, *, placeholder: str | None = None) -> str:
420
+ if self._ptk_session and self._ptk_style and patch_stdout:
421
+ with patch_stdout(): # pragma: no cover - UI specific
422
+ prompt_text = (
423
+ FormattedText([("class:prompt", message)])
424
+ if FormattedText is not None
425
+ else message
426
+ )
427
+ prompt_kwargs: dict[str, Any] = {"style": self._ptk_style}
428
+ if placeholder:
429
+ placeholder_text = (
430
+ FormattedText([("class:placeholder", placeholder)])
431
+ if FormattedText is not None
432
+ else placeholder
433
+ )
434
+ prompt_kwargs["placeholder"] = placeholder_text
435
+ try:
436
+ return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
437
+ except (
438
+ TypeError
439
+ ): # pragma: no cover - compatibility with older prompt_toolkit
440
+ prompt_kwargs.pop("placeholder", None)
441
+ return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
442
+
443
+ if placeholder:
444
+ self.console.print(f"[dim]{placeholder}[/dim]")
445
+ return input(message)
446
+
447
+ def _get_client(self) -> Any: # type: ignore[no-any-return]
448
+ if self._client is None:
449
+ self._client = get_client(self.ctx)
450
+ return self._client
451
+
452
+ def set_contextual_commands(
453
+ self, commands: dict[str, str] | None, *, include_global: bool = True
454
+ ) -> None:
455
+ """Set context-specific commands that should appear in completions."""
456
+
457
+ self._contextual_commands = dict(commands or {})
458
+ self._contextual_include_global = include_global if commands else True
459
+
460
+ def get_contextual_commands(self) -> dict[str, str]: # type: ignore[no-any-return]
461
+ """Return a copy of the currently active contextual commands."""
462
+
463
+ return dict(self._contextual_commands)
464
+
465
+ def should_include_global_commands(self) -> bool:
466
+ """Return whether global slash commands should appear in completions."""
467
+
468
+ return self._contextual_include_global
469
+
470
+ def _remember_agent(self, agent: Any) -> None: # type: ignore[no-any-return]
471
+ agent_data = {
472
+ "id": str(getattr(agent, "id", "")),
473
+ "name": getattr(agent, "name", "") or "",
474
+ "type": getattr(agent, "type", "") or "",
475
+ }
476
+
477
+ self.recent_agents = [
478
+ a for a in self.recent_agents if a.get("id") != agent_data["id"]
479
+ ]
480
+ self.recent_agents.insert(0, agent_data)
481
+ self.recent_agents = self.recent_agents[:5]
482
+
483
+ def _render_header(
484
+ self,
485
+ active_agent: Any | None = None,
486
+ *,
487
+ focus_agent: bool = False,
488
+ initial: bool = False,
489
+ ) -> None:
490
+ if focus_agent and active_agent is not None:
491
+ self._render_focused_agent_header(active_agent)
492
+ return
493
+
494
+ full_header = initial or not self._welcome_rendered
495
+ self._render_main_header(active_agent, full=full_header)
496
+ if full_header:
497
+ self._welcome_rendered = True
498
+
499
+ def _render_focused_agent_header(self, active_agent: Any) -> None:
500
+ """Render header when focusing on a specific agent."""
501
+ agent_id = str(getattr(active_agent, "id", ""))
502
+ agent_name = getattr(active_agent, "name", "") or agent_id
503
+ agent_type = getattr(active_agent, "type", "") or "-"
504
+ description = getattr(active_agent, "description", "") or ""
505
+
506
+ header_grid = Table.grid(expand=True)
507
+ header_grid.add_column(ratio=3)
508
+ header_grid.add_column(ratio=1, justify="right")
509
+
510
+ primary_line = f"[bold]{agent_name}[/bold] · [dim]{agent_type}[/dim] · [cyan]{agent_id}[/cyan]"
511
+ header_grid.add_row(primary_line, "[green]ready[/green]")
512
+
513
+ if description:
514
+ header_grid.add_row(f"[dim]{description}[/dim]", "")
515
+
516
+ keybar = Table.grid(expand=True)
517
+ keybar.add_column(justify="left")
518
+ keybar.add_column(justify="left")
519
+ keybar.add_column(justify="left")
520
+ keybar.add_row(
521
+ "[bold]/help[/bold] [dim]Show commands[/dim]",
522
+ "[bold]/details[/bold] [dim]Agent config[/dim]",
523
+ "[bold]/exit[/bold] [dim]Back[/dim]",
524
+ )
525
+
526
+ header_grid.add_row(keybar, "")
527
+
528
+ self.console.print(
529
+ AIPPanel(header_grid, title="Agent Session", border_style="blue")
530
+ )
531
+
532
+ def _render_main_header(
533
+ self, active_agent: Any | None = None, *, full: bool = False
534
+ ) -> None:
535
+ """Render the main AIP environment header."""
536
+ config = self._load_config()
537
+
538
+ api_url = self._get_api_url(config)
539
+ status = "Configured" if config.get("api_key") else "Not configured"
540
+
541
+ if full:
542
+ lines = [
543
+ self._branding.get_welcome_banner(),
544
+ "",
545
+ f"[dim]API URL[/dim]: {api_url or 'Not configured'}",
546
+ f"[dim]Credentials[/dim]: {status}",
547
+ ]
548
+ extra: list[str] = []
549
+ self._add_agent_info_to_header(extra, active_agent)
550
+ lines.extend(extra)
551
+ self.console.print(
552
+ AIPPanel("\n".join(lines), title="GL AIP Session", border_style="cyan")
553
+ )
554
+ return
555
+
556
+ status_bar = Table.grid(expand=True)
557
+ status_bar.add_column(ratio=2)
558
+ status_bar.add_column(ratio=2)
559
+ status_bar.add_column(ratio=1, justify="right")
560
+ status_bar.add_row(
561
+ "[bold cyan]AIP Palette[/bold cyan]",
562
+ f"[dim]API[/dim]: {api_url or 'Not configured'}",
563
+ "[dim]Type /help for shortcuts[/dim]",
564
+ )
565
+
566
+ if active_agent is not None:
567
+ agent_id = str(getattr(active_agent, "id", ""))
568
+ agent_name = getattr(active_agent, "name", "") or agent_id
569
+ status_bar.add_row(f"[dim]Active[/dim]: {agent_name} [{agent_id}]", "", "")
570
+ elif self.recent_agents:
571
+ recent = self.recent_agents[0]
572
+ label = recent.get("name") or recent.get("id") or "-"
573
+ status_bar.add_row(
574
+ f"[dim]Recent[/dim]: {label} [{recent.get('id', '-')}]",
575
+ "",
576
+ "",
577
+ )
578
+
579
+ self.console.print(AIPPanel(status_bar, border_style="cyan"))
580
+
581
+ def _get_api_url(self, config: dict[str, Any]) -> str | None:
582
+ """Get the API URL from various sources."""
583
+ api_url = None
584
+ if isinstance(self.ctx.obj, dict):
585
+ api_url = self.ctx.obj.get("api_url")
586
+ return api_url or config.get("api_url") or os.getenv("AIP_API_URL")
587
+
588
+ def _add_agent_info_to_header(
589
+ self, lines: list[str], active_agent: Any | None
590
+ ) -> None:
591
+ """Add agent information to header lines."""
592
+ if active_agent is not None:
593
+ agent_id = str(getattr(active_agent, "id", ""))
594
+ agent_name = getattr(active_agent, "name", "") or agent_id
595
+ lines.append(f"[dim]Active agent[/dim]: {agent_name} [{agent_id}]")
596
+ elif self.recent_agents:
597
+ recent = self.recent_agents[0]
598
+ label = recent.get("name") or recent.get("id") or "-"
599
+ lines.append(f"[dim]Recent agent[/dim]: {label} [{recent.get('id', '-')}]")
600
+
601
+ def _render_home_hint(self) -> None:
602
+ self.console.print(
603
+ AIPPanel(
604
+ "Type `/help` for command palette commands, `/agents` to browse agents, or `/exit` (`/q`) to leave the palette.\n"
605
+ "Press Ctrl+C to cancel the current entry, Ctrl+D to quit immediately.",
606
+ title="✨ Getting Started",
607
+ border_style="cyan",
608
+ )
609
+ )
610
+
611
+ def _show_quick_actions(
612
+ self, hints: Iterable[tuple[str, str]], *, title: str = "Quick actions"
613
+ ) -> None:
614
+ hint_list = list(hints)
615
+ if not hint_list:
616
+ return
617
+
618
+ grid = Table.grid(expand=True)
619
+ for _ in hint_list:
620
+ grid.add_column(ratio=1, no_wrap=False)
621
+
622
+ grid.add_row(
623
+ *[
624
+ f"[bold]{command}[/bold]\n[dim]{description}[/dim]"
625
+ for command, description in hint_list
626
+ ]
627
+ )
628
+
629
+ self.console.print(AIPPanel(grid, title=title, border_style="magenta"))
630
+
631
+ def _load_config(self) -> dict[str, Any]:
632
+ if self._config_cache is None:
633
+ try:
634
+ self._config_cache = load_config() or {}
635
+ except Exception:
636
+ self._config_cache = {}
637
+ return self._config_cache
638
+
639
+ def _resolve_agent_from_ref(
640
+ self, client: Any, available_agents: list[Any], ref: str
641
+ ) -> Any | None:
642
+ ref = ref.strip()
643
+ if not ref:
644
+ return None
645
+
646
+ try:
647
+ agent = client.get_agent_by_id(ref)
648
+ if agent:
649
+ return agent
650
+ except Exception: # pragma: no cover - passthrough
651
+ pass
652
+
653
+ matches = [a for a in available_agents if str(getattr(a, "id", "")) == ref]
654
+ if matches:
655
+ return matches[0]
656
+
657
+ try:
658
+ found = client.find_agents(name=ref)
659
+ except Exception: # pragma: no cover - passthrough
660
+ found = []
661
+
662
+ if len(found) == 1:
663
+ return found[0]
664
+
665
+ return None