agentstack-cli 0.6.0rc1__py3-none-manylinux_2_34_aarch64.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,1386 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import abc
5
+ import asyncio
6
+ import base64
7
+ import calendar
8
+ import inspect
9
+ import json
10
+ import random
11
+ import re
12
+ import sys
13
+ import typing
14
+ from enum import StrEnum
15
+ from textwrap import dedent
16
+ from uuid import uuid4
17
+
18
+ import httpx
19
+ from a2a.client import Client
20
+ from a2a.types import (
21
+ AgentCard,
22
+ DataPart,
23
+ FilePart,
24
+ FileWithBytes,
25
+ FileWithUri,
26
+ Message,
27
+ Part,
28
+ Role,
29
+ Task,
30
+ TaskArtifactUpdateEvent,
31
+ TaskState,
32
+ TaskStatus,
33
+ TaskStatusUpdateEvent,
34
+ TextPart,
35
+ )
36
+ from agentstack_sdk.a2a.extensions import (
37
+ EmbeddingFulfillment,
38
+ EmbeddingServiceExtensionClient,
39
+ EmbeddingServiceExtensionSpec,
40
+ FormRequestExtensionSpec,
41
+ FormServiceExtensionSpec,
42
+ LLMFulfillment,
43
+ LLMServiceExtensionClient,
44
+ LLMServiceExtensionSpec,
45
+ PlatformApiExtensionClient,
46
+ PlatformApiExtensionSpec,
47
+ TrajectoryExtensionClient,
48
+ TrajectoryExtensionSpec,
49
+ )
50
+ from agentstack_sdk.a2a.extensions.common.form import (
51
+ CheckboxField,
52
+ CheckboxFieldValue,
53
+ DateField,
54
+ DateFieldValue,
55
+ FormFieldValue,
56
+ FormRender,
57
+ FormResponse,
58
+ MultiSelectField,
59
+ MultiSelectFieldValue,
60
+ SingleSelectField,
61
+ SingleSelectFieldValue,
62
+ TextField,
63
+ TextFieldValue,
64
+ )
65
+ from agentstack_sdk.a2a.extensions.ui.settings import (
66
+ AgentRunSettings,
67
+ CheckboxGroupField,
68
+ CheckboxGroupFieldValue,
69
+ SettingsExtensionSpec,
70
+ SettingsFieldValue,
71
+ SettingsRender,
72
+ )
73
+ from agentstack_sdk.a2a.extensions.ui.settings import (
74
+ CheckboxFieldValue as SettingsCheckboxFieldValue,
75
+ )
76
+ from agentstack_sdk.a2a.extensions.ui.settings import SingleSelectField as SettingsSingleSelectField
77
+ from agentstack_sdk.a2a.extensions.ui.settings import (
78
+ SingleSelectFieldValue as SettingsSingleSelectFieldValue,
79
+ )
80
+ from agentstack_sdk.platform import BuildState, File, ModelProvider, Provider, UserFeedback
81
+ from agentstack_sdk.platform.context import Context, ContextPermissions, ContextToken, Permissions
82
+ from agentstack_sdk.platform.model_provider import ModelCapability
83
+ from InquirerPy import inquirer
84
+ from InquirerPy.base.control import Choice
85
+ from InquirerPy.validator import EmptyInputValidator
86
+ from pydantic import BaseModel
87
+ from rich.box import HORIZONTALS
88
+ from rich.console import ConsoleRenderable, Group, NewLine
89
+ from rich.panel import Panel
90
+ from rich.text import Text
91
+
92
+ from agentstack_cli.commands.build import _server_side_build
93
+ from agentstack_cli.commands.model import ensure_llm_provider
94
+ from agentstack_cli.configuration import Configuration
95
+
96
+ if sys.platform != "win32":
97
+ try:
98
+ # This is necessary for proper handling of arrow keys in interactive input
99
+ import gnureadline as readline
100
+ except ImportError:
101
+ import readline # noqa: F401
102
+
103
+ from collections.abc import Callable
104
+ from pathlib import Path
105
+ from typing import Any
106
+
107
+ import jsonschema
108
+ import rich.json
109
+ import typer
110
+ from rich.markdown import Markdown
111
+ from rich.table import Column
112
+
113
+ from agentstack_cli.api import a2a_client
114
+ from agentstack_cli.async_typer import AsyncTyper, console, create_table, err_console
115
+ from agentstack_cli.utils import (
116
+ announce_server_action,
117
+ confirm_server_action,
118
+ generate_schema_example,
119
+ is_github_url,
120
+ parse_env_var,
121
+ print_log,
122
+ prompt_user,
123
+ remove_nullable,
124
+ status,
125
+ verbosity,
126
+ )
127
+
128
+
129
+ class InteractionMode(StrEnum):
130
+ SINGLE_TURN = "single-turn"
131
+ MULTI_TURN = "multi-turn"
132
+
133
+
134
+ class ProviderUtils(BaseModel):
135
+ @staticmethod
136
+ def detail(provider: Provider) -> dict[str, str] | None:
137
+ ui_extension = [
138
+ ext for ext in provider.agent_card.capabilities.extensions or [] if "ui/agent-detail" in ext.uri
139
+ ]
140
+ return ui_extension[0].params if ui_extension else None
141
+
142
+ @staticmethod
143
+ def last_error(provider: Provider) -> str | None:
144
+ return provider.last_error.message if provider.last_error and provider.state != "ready" else None
145
+
146
+ @staticmethod
147
+ def short_location(provider: Provider) -> str:
148
+ return re.sub(r"[a-z]*.io/i-am-bee/agentstack/", "", provider.source).lower()
149
+
150
+
151
+ app = AsyncTyper()
152
+
153
+ processing_messages = [
154
+ "Asking agents...",
155
+ "Booting up bots...",
156
+ "Calibrating cognition...",
157
+ "Directing drones...",
158
+ "Engaging engines...",
159
+ "Fetching functions...",
160
+ "Gathering goals...",
161
+ "Hardening hypotheses...",
162
+ "Interpreting intentions...",
163
+ "Juggling judgements...",
164
+ "Kernelizing knowledge...",
165
+ "Loading logic...",
166
+ "Mobilizing models...",
167
+ "Nudging networks...",
168
+ "Optimizing outputs...",
169
+ "Prompting pipelines...",
170
+ "Quantizing queries...",
171
+ "Refining responses...",
172
+ "Scaling stacks...",
173
+ "Tuning transformers...",
174
+ "Unifying understandings...",
175
+ "Vectorizing values...",
176
+ "Wiring workflows...",
177
+ "Xecuting xperiments...",
178
+ "Yanking YAMLs...",
179
+ "Zipping zettabytes...",
180
+ ]
181
+
182
+ configuration = Configuration()
183
+
184
+
185
+ @app.command("add")
186
+ async def add_agent(
187
+ location: typing.Annotated[
188
+ str | None, typer.Argument(help="Agent location (public docker image or github url)")
189
+ ] = None,
190
+ dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
191
+ verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
192
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
193
+ ) -> None:
194
+ """Add a docker image or GitHub repository. [Admin only]
195
+
196
+ This command supports a variety of GitHub URL formats for deploying agents:
197
+
198
+ - **Basic URL**: `https://github.com/myorg/myrepo`
199
+ - **Git Protocol URL**: `git+https://github.com/myorg/myrepo`
200
+ - **URL with .git suffix**: `https://github.com/myorg/myrepo.git`
201
+ - **URL with Version Tag**: `https://github.com/myorg/myrepo@v1.0.0`
202
+ - **URL with Branch Name**: `https://github.com/myorg/myrepo@my-branch`
203
+ - **URL with Subfolder Path**: `https://github.com/myorg/myrepo#path=/path/to/agent`
204
+ - **Combined Formats**: `https://github.com/myorg/myrepo.git@v1.0.0#path=/path/to/agent`
205
+ - **Enterprise GitHub**: `https://github.mycompany.com/myorg/myrepo`
206
+ - **With a custom Dockerfile location**: `agentstack add --dockerfile /my-agent/path/to/Dockerfile "https://github.com/my-org/my-awesome-agents@main#path=/my-agent"`
207
+
208
+ [aliases: install]
209
+ """
210
+ if location is None:
211
+ repo_input = (
212
+ await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
213
+ message="Enter GitHub repository (owner/repo or full URL):",
214
+ ).execute_async()
215
+ or ""
216
+ )
217
+
218
+ match = re.search(r"^(?:(?:https?://)?(?:www\.)?github\.com/)?([^/]+)/([^/?&]+)", repo_input)
219
+ if not match:
220
+ raise ValueError(f"Invalid GitHub URL format: {repo_input}. Expected 'owner/repo' or a full GitHub URL.")
221
+
222
+ owner, repo = match.group(1), match.group(2).removesuffix(".git")
223
+
224
+ async with httpx.AsyncClient() as client:
225
+ response = await client.get(
226
+ f"https://api.github.com/repos/{owner}/{repo}/tags",
227
+ headers={"Accept": "application/vnd.github.v3+json"},
228
+ )
229
+ tags = [tag["name"] for tag in response.json()] if response.status_code == 200 else []
230
+
231
+ if tags:
232
+ selected_tag = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
233
+ message="Select a tag to use:",
234
+ choices=tags,
235
+ ).execute_async()
236
+ else:
237
+ selected_tag = (
238
+ await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
239
+ message="Enter tag to use:",
240
+ ).execute_async()
241
+ or "main"
242
+ )
243
+
244
+ location = f"https://github.com/{owner}/{repo}@{selected_tag}"
245
+
246
+ url = announce_server_action(f"Installing agent '{location}' for")
247
+ await confirm_server_action("Proceed with installing this agent on", url=url, yes=yes)
248
+ with verbosity(verbose):
249
+ if is_github_url(location):
250
+ console.info(f"Assuming GitHub repository, attempting to build agent from [bold]{location}[/bold]")
251
+ with status("Building agent"):
252
+ build = await _server_side_build(location, dockerfile, add=True, verbose=verbose)
253
+ if build.status != BuildState.COMPLETED:
254
+ error = build.error_message or "see logs above for details"
255
+ raise RuntimeError(f"Agent build failed: {error}")
256
+ else:
257
+ if dockerfile:
258
+ raise ValueError("Dockerfile can be specified only if location is a GitHub url")
259
+ console.info(f"Assuming public docker image or network address, attempting to add {location}")
260
+ with status("Registering agent to platform"):
261
+ async with configuration.use_platform_client():
262
+ await Provider.create(location=location)
263
+ console.success(f"Agent [bold]{location}[/bold] added to platform")
264
+ await list_agents()
265
+
266
+
267
+ @app.command("update")
268
+ async def update_agent(
269
+ search_path: typing.Annotated[
270
+ str | None, typer.Argument(help="Short ID, agent name or part of the provider location of agent to replace")
271
+ ] = None,
272
+ location: typing.Annotated[
273
+ str | None, typer.Argument(help="Agent location (public docker image or github url)")
274
+ ] = None,
275
+ dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
276
+ verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
277
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
278
+ ) -> None:
279
+ """Upgrade agent to a newer docker image or build from GitHub repository. [Admin only]"""
280
+ with verbosity(verbose):
281
+ async with configuration.use_platform_client():
282
+ providers = await Provider.list()
283
+
284
+ if search_path is None:
285
+ if not providers:
286
+ console.error("No agents found. Add an agent first using 'agentstack agent add'.")
287
+ sys.exit(1)
288
+
289
+ provider_choices = [
290
+ Choice(value=p, name=f"{p.agent_card.name} ({ProviderUtils.short_location(p)})") for p in providers
291
+ ]
292
+ provider = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
293
+ message="Select an agent to update:",
294
+ choices=provider_choices,
295
+ ).execute_async()
296
+ if not provider:
297
+ console.error("No agent selected. Exiting.")
298
+ sys.exit(1)
299
+ else:
300
+ provider = select_provider(search_path, providers=providers)
301
+
302
+ if location is None and is_github_url(provider.source):
303
+ match = re.search(r"^(?:(?:https?://)?(?:www\.)?github\.com/)?([^/]+)/([^/@?&]+)", provider.source)
304
+ if match:
305
+ owner, repo = match.group(1), match.group(2).removesuffix(".git")
306
+
307
+ async with httpx.AsyncClient() as client:
308
+ response = await client.get(
309
+ f"https://api.github.com/repos/{owner}/{repo}/tags",
310
+ headers={"Accept": "application/vnd.github.v3+json"},
311
+ )
312
+ tags = [tag["name"] for tag in response.json()] if response.status_code == 200 else []
313
+
314
+ if tags:
315
+ selected_tag = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
316
+ message="Select a new tag to use:",
317
+ choices=tags,
318
+ ).execute_async()
319
+ if selected_tag:
320
+ location = f"https://github.com/{owner}/{repo}@{selected_tag}"
321
+
322
+ if location is None:
323
+ location = (
324
+ await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
325
+ message="Enter new agent location (public docker image or github url):",
326
+ default=provider.source,
327
+ ).execute_async()
328
+ or ""
329
+ )
330
+
331
+ if not location:
332
+ console.error("No location provided. Exiting.")
333
+ sys.exit(1)
334
+
335
+ url = announce_server_action(f"Upgrading agent from '{provider.source}' to {location}")
336
+ await confirm_server_action("Proceed with upgrading agent on", url=url, yes=yes)
337
+
338
+ if is_github_url(location):
339
+ console.info(f"Assuming GitHub repository, attempting to build agent from [bold]{location}[/bold]")
340
+ with status("Building agent"):
341
+ build = await _server_side_build(
342
+ github_url=location, dockerfile=dockerfile, replace=provider.id, verbose=verbose
343
+ )
344
+ if build.status != BuildState.COMPLETED:
345
+ error = build.error_message or "see logs above for details"
346
+ raise RuntimeError(f"Agent build failed: {error}")
347
+ else:
348
+ if dockerfile:
349
+ raise ValueError("Dockerfile can be specified only if location is a GitHub url")
350
+ console.info(f"Assuming public docker image or network address, attempting to add {location}")
351
+ with status("Upgrading agent in the platform"):
352
+ async with configuration.use_platform_client():
353
+ await provider.patch(location=location)
354
+ console.success(f"Agent [bold]{location}[/bold] added to platform")
355
+ await list_agents()
356
+
357
+
358
+ def search_path_match_providers(search_path: str, providers: list[Provider]) -> dict[str, Provider]:
359
+ search_path = search_path.lower()
360
+ return {
361
+ p.id: p
362
+ for p in providers
363
+ if (
364
+ search_path in p.id.lower()
365
+ or search_path in p.agent_card.name.lower()
366
+ or search_path in ProviderUtils.short_location(p)
367
+ )
368
+ }
369
+
370
+
371
+ def select_provider(search_path: str, providers: list[Provider]):
372
+ provider_candidates = search_path_match_providers(search_path, providers)
373
+ if len(provider_candidates) != 1:
374
+ provider_candidates = [f" - {c}" for c in provider_candidates]
375
+ remove_providers_detail = ":\n" + "\n".join(provider_candidates) if provider_candidates else ""
376
+ raise ValueError(f"{len(provider_candidates)} matching agents{remove_providers_detail}")
377
+ [selected_provider] = provider_candidates.values()
378
+ return selected_provider
379
+
380
+
381
+ async def select_providers_multi(search_path: str, providers: list[Provider]) -> list[Provider]:
382
+ """Select multiple providers matching the search path."""
383
+ provider_candidates = search_path_match_providers(search_path, providers)
384
+ if not provider_candidates:
385
+ raise ValueError(f"No matching agents found for '{search_path}'")
386
+
387
+ if len(provider_candidates) == 1:
388
+ return list(provider_candidates.values())
389
+
390
+ # Multiple matches - show selection menu
391
+ choices = [Choice(value=p.id, name=f"{p.agent_card.name} - {p.id}") for p in provider_candidates.values()]
392
+
393
+ selected_ids = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
394
+ message="Select agents to remove (use ↑/↓ to navigate, Space to select):", choices=choices
395
+ ).execute_async()
396
+
397
+ return [provider_candidates[pid] for pid in (selected_ids or [])]
398
+
399
+
400
+ @app.command("remove | uninstall | rm | delete")
401
+ async def uninstall_agent(
402
+ search_path: typing.Annotated[
403
+ str, typer.Argument(help="Short ID, agent name or part of the provider location")
404
+ ] = "",
405
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
406
+ all: typing.Annotated[bool, typer.Option("--all", "-a", help="Remove all agents without selection.")] = False,
407
+ ) -> None:
408
+ """Remove agent. [Admin only]"""
409
+ if search_path and all:
410
+ console.error(
411
+ "[bold]Cannot specify both --all and a search path."
412
+ " Use --all to remove all agents, or provide a search path for specific agents."
413
+ "[/bold]"
414
+ )
415
+ raise typer.Exit(1)
416
+
417
+ async with configuration.use_platform_client():
418
+ providers = await Provider.list()
419
+ if len(providers) == 0:
420
+ console.info("No agents found to remove.")
421
+ return
422
+
423
+ if all:
424
+ selected_providers = providers
425
+ else:
426
+ selected_providers = await select_providers_multi(search_path, providers)
427
+ if not selected_providers:
428
+ console.info("No agents selected for removal, exiting.")
429
+ return
430
+ elif len(selected_providers) == 1:
431
+ agent_names = f"{selected_providers[0].agent_card.name} - {selected_providers[0].id.split('-', 1)[0]}"
432
+ else:
433
+ agent_names = "\n".join([f" - {p.agent_card.name} - {p.id.split('-', 1)[0]}" for p in selected_providers])
434
+
435
+ message = f"\n[bold]Selected agents to remove:[/bold]\n{agent_names}\n from "
436
+
437
+ url = announce_server_action(message)
438
+ await confirm_server_action("Proceed with removing these agents from", url=url, yes=yes)
439
+
440
+ with console.status("Uninstalling agent(s) (may take a few minutes)...", spinner="dots"):
441
+ delete_tasks = [Provider.delete(provider.id) for provider in selected_providers]
442
+ results = await asyncio.gather(*delete_tasks, return_exceptions=True)
443
+
444
+ # Check results for exceptions
445
+ for provider, result in zip(selected_providers, results, strict=True):
446
+ if isinstance(result, Exception):
447
+ err_console.print(f"Failed to delete {provider.agent_card.name}: {result}")
448
+ # else: deletion succeeded
449
+
450
+ await list_agents()
451
+
452
+
453
+ @app.command("logs")
454
+ async def stream_logs(
455
+ search_path: typing.Annotated[
456
+ str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
457
+ ],
458
+ ):
459
+ """Stream agent provider logs. [Admin only]"""
460
+ announce_server_action(f"Streaming logs for '{search_path}' from")
461
+ async with configuration.use_platform_client():
462
+ provider = select_provider(search_path, await Provider.list()).id
463
+ async for message in Provider.stream_logs(provider):
464
+ print_log(message, ansi_mode=True)
465
+
466
+
467
+ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
468
+ """Ask user to fill a form using inquirer."""
469
+ form_values: dict[str, FormFieldValue] = {}
470
+
471
+ console.print("[bold]Form input[/bold]" + (f": {form_render.title}" if form_render.title else ""))
472
+ if form_render.description:
473
+ console.print(f"{form_render.description}\n")
474
+
475
+ for field in form_render.fields:
476
+ if isinstance(field, TextField):
477
+ answer = await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
478
+ message=field.label + ":",
479
+ default=field.default_value or "",
480
+ validate=EmptyInputValidator() if field.required else None,
481
+ ).execute_async()
482
+ form_values[field.id] = TextFieldValue(value=answer)
483
+ elif isinstance(field, SingleSelectField):
484
+ choices = [Choice(value=opt.id, name=opt.label) for opt in field.options]
485
+ answer = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
486
+ message=field.label + ":",
487
+ choices=choices,
488
+ default=field.default_value,
489
+ validate=EmptyInputValidator() if field.required else None,
490
+ ).execute_async()
491
+ form_values[field.id] = SingleSelectFieldValue(value=answer)
492
+ elif isinstance(field, MultiSelectField):
493
+ choices = [Choice(value=opt.id, name=opt.label) for opt in field.options]
494
+ answer = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
495
+ message=field.label + ":",
496
+ choices=choices,
497
+ default=field.default_value,
498
+ validate=EmptyInputValidator() if field.required else None,
499
+ ).execute_async()
500
+ form_values[field.id] = MultiSelectFieldValue(value=answer)
501
+
502
+ elif isinstance(field, DateField):
503
+ year = await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
504
+ message=f"{field.label} (year):",
505
+ validate=EmptyInputValidator() if field.required else None,
506
+ filter=lambda y: y.strip(),
507
+ ).execute_async()
508
+ if not year:
509
+ continue
510
+ month = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
511
+ message=f"{field.label} (month):",
512
+ validate=EmptyInputValidator() if field.required else None,
513
+ choices=[
514
+ Choice(
515
+ value=str(i).zfill(2),
516
+ name=f"{i:02d} - {calendar.month_name[i]}",
517
+ )
518
+ for i in range(1, 13)
519
+ ],
520
+ ).execute_async()
521
+ if not month:
522
+ continue
523
+ day = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
524
+ message=f"{field.label} (day):",
525
+ validate=EmptyInputValidator() if field.required else None,
526
+ choices=[
527
+ Choice(value=str(i).zfill(2), name=str(i).zfill(2))
528
+ for i in range(1, calendar.monthrange(int(year), int(month))[1] + 1)
529
+ ],
530
+ ).execute_async()
531
+ if not day:
532
+ continue
533
+ full_date = f"{year}-{month}-{day}"
534
+ form_values[field.id] = DateFieldValue(value=full_date)
535
+ elif isinstance(field, CheckboxField):
536
+ answer = await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
537
+ message=field.label + ":",
538
+ default=field.default_value,
539
+ long_instruction=field.content or "",
540
+ ).execute_async()
541
+ form_values[field.id] = CheckboxFieldValue(value=answer)
542
+ console.print()
543
+ return FormResponse(values=form_values)
544
+
545
+
546
+ async def _ask_settings_questions(settings_render: SettingsRender) -> AgentRunSettings:
547
+ """Ask user to configure settings using inquirer."""
548
+ settings_values: dict[str, SettingsFieldValue] = {}
549
+
550
+ console.print("[bold]Agent Settings[/bold]\n")
551
+
552
+ for field in settings_render.fields:
553
+ if isinstance(field, CheckboxGroupField):
554
+ checkbox_values: dict[str, SettingsCheckboxFieldValue] = {}
555
+ for checkbox in field.fields:
556
+ answer = await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
557
+ message=checkbox.label + ":",
558
+ default=checkbox.default_value,
559
+ ).execute_async()
560
+ checkbox_values[checkbox.id] = SettingsCheckboxFieldValue(value=answer)
561
+ settings_values[field.id] = CheckboxGroupFieldValue(values=checkbox_values)
562
+ elif isinstance(field, SettingsSingleSelectField):
563
+ choices = [Choice(value=opt.value, name=opt.label) for opt in field.options]
564
+ answer = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
565
+ message=field.label + ":",
566
+ choices=choices,
567
+ default=field.default_value,
568
+ ).execute_async()
569
+ settings_values[field.id] = SettingsSingleSelectFieldValue(value=answer)
570
+ else:
571
+ raise ValueError(f"Unsupported settings field type: {type(field).__name__}")
572
+
573
+ console.print()
574
+ return AgentRunSettings(values=settings_values)
575
+
576
+
577
+ async def _run_agent(
578
+ client: Client,
579
+ input: str | DataPart | FormResponse,
580
+ agent_card: AgentCard,
581
+ context_token: ContextToken,
582
+ settings: AgentRunSettings | None = None,
583
+ dump_files_path: Path | None = None,
584
+ handle_input: Callable[[], str] | None = None,
585
+ task_id: str | None = None,
586
+ ) -> None:
587
+ console_status = console.status(random.choice(processing_messages), spinner="dots")
588
+ console_status.start()
589
+ console_status_stopped = False
590
+
591
+ log_type = None
592
+
593
+ trajectory_spec = TrajectoryExtensionSpec.from_agent_card(agent_card)
594
+ trajectory_extension = TrajectoryExtensionClient(trajectory_spec) if trajectory_spec else None
595
+ llm_spec = LLMServiceExtensionSpec.from_agent_card(agent_card)
596
+ embedding_spec = EmbeddingServiceExtensionSpec.from_agent_card(agent_card)
597
+ platform_extension_spec = PlatformApiExtensionSpec.from_agent_card(agent_card)
598
+
599
+ async with configuration.use_platform_client():
600
+ metadata = (
601
+ (
602
+ LLMServiceExtensionClient(llm_spec).fulfillment_metadata(
603
+ llm_fulfillments={
604
+ key: LLMFulfillment(
605
+ api_base="{platform_url}/api/v1/openai/",
606
+ api_key=context_token.token.get_secret_value(),
607
+ api_model=(
608
+ await ModelProvider.match(
609
+ suggested_models=demand.suggested,
610
+ capability=ModelCapability.LLM,
611
+ )
612
+ )[0].model_id,
613
+ )
614
+ for key, demand in llm_spec.params.llm_demands.items()
615
+ }
616
+ )
617
+ if llm_spec
618
+ else {}
619
+ )
620
+ | (
621
+ EmbeddingServiceExtensionClient(embedding_spec).fulfillment_metadata(
622
+ embedding_fulfillments={
623
+ key: EmbeddingFulfillment(
624
+ api_base="{platform_url}/api/v1/openai/",
625
+ api_key=context_token.token.get_secret_value(),
626
+ api_model=(
627
+ await ModelProvider.match(
628
+ suggested_models=demand.suggested,
629
+ capability=ModelCapability.EMBEDDING,
630
+ )
631
+ )[0].model_id,
632
+ )
633
+ for key, demand in embedding_spec.params.embedding_demands.items()
634
+ }
635
+ )
636
+ if embedding_spec
637
+ else {}
638
+ )
639
+ | (
640
+ {
641
+ FormServiceExtensionSpec.URI: {
642
+ "form_fulfillments": {"initial_form": typing.cast(FormResponse, input).model_dump(mode="json")}
643
+ }
644
+ }
645
+ if isinstance(input, FormResponse)
646
+ else {}
647
+ )
648
+ | (
649
+ PlatformApiExtensionClient(platform_extension_spec).api_auth_metadata(
650
+ auth_token=context_token.token, expires_at=context_token.expires_at
651
+ )
652
+ if platform_extension_spec
653
+ else {}
654
+ )
655
+ | ({SettingsExtensionSpec.URI: settings.model_dump(mode="json")} if settings else {})
656
+ )
657
+
658
+ msg = Message(
659
+ message_id=str(uuid4()),
660
+ parts=[
661
+ Part(
662
+ root=TextPart(text=input)
663
+ if isinstance(input, str)
664
+ else TextPart(text="")
665
+ if isinstance(input, FormResponse)
666
+ else input
667
+ )
668
+ ],
669
+ role=Role.user,
670
+ task_id=task_id,
671
+ context_id=context_token.context_id,
672
+ metadata=metadata,
673
+ )
674
+
675
+ stream = client.send_message(msg)
676
+
677
+ while True:
678
+ async for event in stream:
679
+ if not console_status_stopped:
680
+ console_status_stopped = True
681
+ console_status.stop()
682
+ match event:
683
+ case Message(task_id=task_id) as message:
684
+ console.print(
685
+ dedent(
686
+ """\
687
+ ⚠️ [yellow]Warning[/yellow]:
688
+ Receiving message event outside of task is not supported.
689
+ Please use agentstack-sdk for writing your agents or ensure you always create a task first
690
+ using TaskUpdater() from a2a SDK: see https://a2a-protocol.org/v0.3.0/topics/life-of-a-task
691
+ """
692
+ )
693
+ )
694
+ # Basic fallback
695
+ for part in message.parts:
696
+ if isinstance(part.root, TextPart):
697
+ console.print(part.root.text)
698
+ case Task(id=task_id), TaskStatusUpdateEvent(
699
+ status=TaskStatus(state=TaskState.completed, message=message)
700
+ ):
701
+ console.print() # Add newline after completion
702
+ return
703
+ case Task(id=task_id), TaskStatusUpdateEvent(
704
+ status=TaskStatus(state=TaskState.working | TaskState.submitted, message=message)
705
+ ):
706
+ # Handle streaming content during working state
707
+ if message:
708
+ if trajectory_extension and (trajectory := trajectory_extension.parse_server_metadata(message)):
709
+ if update_kind := trajectory.title:
710
+ if update_kind != log_type:
711
+ if log_type is not None:
712
+ err_console.print()
713
+ err_console.print(f"{update_kind}: ", style="dim", end="")
714
+ log_type = update_kind
715
+ err_console.print(trajectory.content or "", style="dim", end="")
716
+ else:
717
+ # This is regular message content
718
+ if log_type:
719
+ console.print()
720
+ log_type = None
721
+ for part in message.parts:
722
+ if isinstance(part.root, TextPart):
723
+ console.print(part.root.text, end="")
724
+ case Task(id=task_id), TaskStatusUpdateEvent(
725
+ status=TaskStatus(state=TaskState.input_required, message=message)
726
+ ):
727
+ if handle_input is None:
728
+ raise ValueError("Agent requires input but no input handler provided")
729
+
730
+ if form_metadata := (
731
+ message.metadata.get(FormRequestExtensionSpec.URI) if message and message.metadata else None
732
+ ):
733
+ stream = client.send_message(
734
+ Message(
735
+ message_id=str(uuid4()),
736
+ parts=[],
737
+ role=Role.user,
738
+ task_id=task_id,
739
+ context_id=context_token.context_id,
740
+ metadata={
741
+ FormRequestExtensionSpec.URI: (
742
+ await _ask_form_questions(FormRender.model_validate(form_metadata))
743
+ ).model_dump(mode="json")
744
+ },
745
+ )
746
+ )
747
+ break
748
+
749
+ text = ""
750
+ for part in message.parts if message else []:
751
+ if isinstance(part.root, TextPart):
752
+ text = part.root.text
753
+ console.print(f"\n[bold]Agent requires your input[/bold]: {text}\n")
754
+ user_input = handle_input()
755
+ stream = client.send_message(
756
+ Message(
757
+ message_id=str(uuid4()),
758
+ parts=[Part(root=TextPart(text=user_input))],
759
+ role=Role.user,
760
+ task_id=task_id,
761
+ context_id=context_token.context_id,
762
+ )
763
+ )
764
+ break
765
+ case Task(id=task_id), TaskStatusUpdateEvent(
766
+ status=TaskStatus(
767
+ state=TaskState.canceled | TaskState.failed | TaskState.rejected as status,
768
+ message=message,
769
+ )
770
+ ):
771
+ error = ""
772
+ if message and message.parts and isinstance(message.parts[0].root, TextPart):
773
+ error = message.parts[0].root.text
774
+ console.print(f"\n:boom: [red][bold]Task {status.value}[/bold][/red]")
775
+ console.print(Markdown(error))
776
+ return
777
+ case Task(id=task_id), TaskStatusUpdateEvent(
778
+ status=TaskStatus(state=TaskState.auth_required, message=message)
779
+ ):
780
+ console.print("[yellow]Authentication required[/yellow]")
781
+ return
782
+ case Task(id=task_id), TaskStatusUpdateEvent(status=TaskStatus(state=state, message=message)):
783
+ console.print(f"[yellow]Unknown task status: {state}[/yellow]")
784
+
785
+ case Task(id=task_id), TaskArtifactUpdateEvent(artifact=artifact):
786
+ if dump_files_path is None:
787
+ continue
788
+ dump_files_path.mkdir(parents=True, exist_ok=True)
789
+ full_path = dump_files_path / (artifact.name or "unnamed").lstrip("/")
790
+ full_path.resolve().relative_to(dump_files_path.resolve())
791
+ full_path.parent.mkdir(parents=True, exist_ok=True)
792
+ try:
793
+ for part in artifact.parts[:1]:
794
+ match part.root:
795
+ case FilePart():
796
+ match part.root.file:
797
+ case FileWithBytes(bytes=bytes_str):
798
+ full_path.write_bytes(base64.b64decode(bytes_str))
799
+ case FileWithUri(uri=uri):
800
+ if uri.startswith("agentstack://"):
801
+ async with File.load_content(uri.removeprefix("agentstack://")) as file:
802
+ full_path.write_bytes(file.content)
803
+ else:
804
+ async with httpx.AsyncClient() as httpx_client:
805
+ full_path.write_bytes((await httpx_client.get(uri)).content)
806
+ console.print(f"📁 Saved {full_path}")
807
+ case TextPart(text=text):
808
+ full_path.write_text(text)
809
+ case _:
810
+ console.print(f"⚠️ Artifact part {type(part).__name__} is not supported")
811
+ if len(artifact.parts) > 1:
812
+ console.print("⚠️ Artifact with more than 1 part are not supported.")
813
+ except ValueError:
814
+ console.print(f"⚠️ Skipping artifact {artifact.name} - outside dump directory")
815
+ else:
816
+ break # Stream ended normally
817
+
818
+
819
+ class InteractiveCommand(abc.ABC):
820
+ args: typing.ClassVar[list[str]] = []
821
+ command: str
822
+
823
+ @abc.abstractmethod
824
+ def handle(self, args_str: str | None = None): ...
825
+
826
+ @property
827
+ def enabled(self) -> bool:
828
+ return True
829
+
830
+ def completion_opts(self) -> dict[str, Any | None] | None:
831
+ return None
832
+
833
+
834
+ class Quit(InteractiveCommand):
835
+ """Quit"""
836
+
837
+ command = "q"
838
+
839
+ def handle(self, args_str: str | None = None):
840
+ sys.exit(0)
841
+
842
+
843
+ class ShowConfig(InteractiveCommand):
844
+ """Show available and currently set configuration options"""
845
+
846
+ command = "show-config"
847
+
848
+ def __init__(self, config_schema: dict[str, Any] | None, config: dict[str, Any]):
849
+ self.config_schema = config_schema or {}
850
+ self.config = config
851
+
852
+ @property
853
+ def enabled(self) -> bool:
854
+ return bool(self.config_schema)
855
+
856
+ def handle(self, args_str: str | None = None):
857
+ with create_table(Column("Key", ratio=1), Column("Type", ratio=3), Column("Example", ratio=2)) as schema_table:
858
+ for prop, schema in self.config_schema["properties"].items():
859
+ required_schema = remove_nullable(schema)
860
+ schema_table.add_row(
861
+ prop,
862
+ json.dumps(required_schema),
863
+ json.dumps(generate_schema_example(required_schema)), # pyright: ignore [reportArgumentType]
864
+ )
865
+
866
+ renderables = [
867
+ NewLine(),
868
+ Panel(schema_table, title="Configuration schema", title_align="left"),
869
+ ]
870
+
871
+ if self.config:
872
+ with create_table(Column("Key", ratio=1), Column("Value", ratio=5)) as config_table:
873
+ for key, value in self.config.items():
874
+ config_table.add_row(key, json.dumps(value))
875
+ renderables += [
876
+ NewLine(),
877
+ Panel(config_table, title="Current configuration", title_align="left"),
878
+ ]
879
+ panel = Panel(
880
+ Group(
881
+ *renderables,
882
+ NewLine(),
883
+ console.render_str("[b]Hint[/b]: Use /set <key> <value> to set an agent configuration property."),
884
+ ),
885
+ title="Agent configuration",
886
+ box=HORIZONTALS,
887
+ )
888
+ console.print(panel)
889
+
890
+
891
+ class Set(InteractiveCommand):
892
+ """Set agent configuration value. Use JSON syntax for more complex objects"""
893
+
894
+ args: typing.ClassVar[list[str]] = ["<key>", "<value>"]
895
+ command = "set"
896
+
897
+ def __init__(self, config_schema: dict[str, Any] | None, config: dict[str, Any]):
898
+ self.config_schema = config_schema or {}
899
+ self.config = config
900
+
901
+ @property
902
+ def enabled(self) -> bool:
903
+ return bool(self.config_schema)
904
+
905
+ def handle(self, args_str: str | None = None):
906
+ args_str = args_str or ""
907
+ args = args_str.split(" ", maxsplit=1)
908
+ if not args_str or len(args) != 2:
909
+ raise ValueError(f"The command {self.command} takes exactly two arguments: <key> and <value>.")
910
+ key, value = args
911
+ if key not in self.config_schema["properties"]:
912
+ raise ValueError(f"Unknown option {key}")
913
+ try:
914
+ if value.strip("\"'") == value and not value.startswith("{") and not value.startswith("["):
915
+ value = f'"{value}"'
916
+ json_value = json.loads(value)
917
+ tmp_config = {**self.config, key: json_value}
918
+ jsonschema.validate(tmp_config, self.config_schema)
919
+ self.config[key] = json_value
920
+ console.print("Config:", self.config)
921
+ except json.JSONDecodeError as ex:
922
+ raise ValueError(f"The provided value cannot be parsed into JSON: {value}") from ex
923
+ except jsonschema.ValidationError as ex:
924
+ err_console.print(json.dumps(generate_schema_example(self.config_schema["properties"][key])))
925
+ raise ValueError(f"Invalid value for key {key}: {ex}") from ex
926
+
927
+ def completion_opts(self) -> dict[str, Any | None] | None:
928
+ return {
929
+ key: {json.dumps(generate_schema_example(schema))}
930
+ for key, schema in self.config_schema["properties"].items()
931
+ }
932
+
933
+
934
+ class Help(InteractiveCommand):
935
+ """Show this help."""
936
+
937
+ command = "?"
938
+
939
+ def __init__(self, commands: list[InteractiveCommand], splash_screen: ConsoleRenderable | None = None):
940
+ [self.config_command] = [command for command in commands if isinstance(command, ShowConfig)] or [None]
941
+ self.splash_screen = splash_screen
942
+ self.commands = [self, *commands]
943
+
944
+ def handle(self, args_str: str | None = None):
945
+ if self.splash_screen:
946
+ console.print(self.splash_screen)
947
+ if self.config_command:
948
+ self.config_command.handle()
949
+ console.print()
950
+ with create_table("command", "arguments", "description") as table:
951
+ for command in self.commands:
952
+ table.add_row(f"/{command.command}", " ".join(command.args or ["n/a"]), inspect.getdoc(command))
953
+ console.print(table)
954
+
955
+
956
+ def _create_input_handler(
957
+ commands: list[InteractiveCommand],
958
+ prompt: str | None = None,
959
+ choice: list[str] | None = None,
960
+ optional: bool = False,
961
+ placeholder: str | None = None,
962
+ splash_screen: ConsoleRenderable | None = None,
963
+ ) -> Callable[[], str]:
964
+ choice = choice or []
965
+ commands = [cmd for cmd in commands if cmd.enabled]
966
+ commands = [Quit(), *commands]
967
+ commands = [Help(commands, splash_screen=splash_screen), *commands]
968
+ commands_router = {f"/{cmd.command}": cmd for cmd in commands}
969
+ completer = {
970
+ **{f"/{cmd.command}": cmd.completion_opts() for cmd in commands},
971
+ **dict.fromkeys(choice),
972
+ }
973
+
974
+ valid_options = set(choice) | commands_router.keys()
975
+
976
+ def validate(text: str):
977
+ if optional and not text:
978
+ return True
979
+ return text in valid_options if choice else bool(text)
980
+
981
+ def handler() -> str:
982
+ from prompt_toolkit.completion import NestedCompleter
983
+ from prompt_toolkit.validation import Validator
984
+
985
+ while True:
986
+ try:
987
+ input = prompt_user(
988
+ prompt=prompt,
989
+ placeholder=placeholder,
990
+ completer=NestedCompleter.from_nested_dict(completer),
991
+ validator=Validator.from_callable(validate),
992
+ open_autocomplete_by_default=bool(choice),
993
+ )
994
+ if input.startswith("/"):
995
+ command, *arg_str = input.split(" ", maxsplit=1)
996
+ if command not in commands_router:
997
+ raise ValueError(f"Unknown command: {command}")
998
+ commands_router[command].handle(*arg_str)
999
+ continue
1000
+ return input
1001
+ except ValueError as exc:
1002
+ err_console.print(str(exc))
1003
+ except EOFError as exc:
1004
+ raise KeyboardInterrupt from exc
1005
+
1006
+ return handler
1007
+
1008
+
1009
+ @app.command("run")
1010
+ async def run_agent(
1011
+ search_path: typing.Annotated[
1012
+ str | None,
1013
+ typer.Argument(
1014
+ help="Short ID, agent name or part of the provider location",
1015
+ ),
1016
+ ] = None,
1017
+ input: typing.Annotated[
1018
+ str | None,
1019
+ typer.Argument(
1020
+ help="Agent input as text or JSON",
1021
+ ),
1022
+ ] = None,
1023
+ dump_files: typing.Annotated[
1024
+ Path | None, typer.Option(help="Folder path to save any files returned by the agent")
1025
+ ] = None,
1026
+ ) -> None:
1027
+ """Run an agent."""
1028
+ async with configuration.use_platform_client():
1029
+ providers = await Provider.list()
1030
+ await ensure_llm_provider()
1031
+
1032
+ if search_path is None:
1033
+ if not providers:
1034
+ err_console.error("No agents found. Add an agent first using 'agentstack agent add'.")
1035
+ sys.exit(1)
1036
+ search_path = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
1037
+ message="Select an agent to run:",
1038
+ choices=[provider.agent_card.name for provider in providers],
1039
+ ).execute_async()
1040
+ if search_path is None:
1041
+ err_console.error("No agent selected. Exiting.")
1042
+ sys.exit(1)
1043
+
1044
+ announce_server_action(f"Running agent '{search_path}' on")
1045
+ provider = select_provider(search_path, providers=providers)
1046
+
1047
+ context = await Context.create(
1048
+ provider_id=provider.id,
1049
+ # TODO: remove metadata after UI migration
1050
+ metadata={"provider_id": provider.id, "agent_name": provider.agent_card.name},
1051
+ )
1052
+ context_token = await context.generate_token(
1053
+ grant_global_permissions=Permissions(llm={"*"}, embeddings={"*"}, a2a_proxy={"*"}, providers={"read"}),
1054
+ grant_context_permissions=ContextPermissions(files={"*"}, vector_stores={"*"}, context_data={"*"}),
1055
+ )
1056
+
1057
+ agent = provider.agent_card
1058
+
1059
+ if provider.state == "missing":
1060
+ console.print("Starting provider (this might take a while)...")
1061
+ if provider.state not in {"ready", "running", "starting", "missing", "online", "offline"}:
1062
+ err_console.print(f":boom: Agent is not in a ready state: {provider.state}, {provider.last_error}\nRetrying...")
1063
+
1064
+ ui_annotations = ProviderUtils.detail(provider) or {}
1065
+ interaction_mode = ui_annotations.get("interaction_mode")
1066
+
1067
+ user_greeting = ui_annotations.get("user_greeting", None) or "How can I help you?"
1068
+
1069
+ splash_screen = Group(Markdown(f"# {agent.name} \n{agent.description}"), NewLine())
1070
+ handle_input = _create_input_handler([], splash_screen=splash_screen)
1071
+
1072
+ settings_render = next(
1073
+ (
1074
+ SettingsRender.model_validate(ext.params)
1075
+ for ext in agent.capabilities.extensions or ()
1076
+ if ext.uri == SettingsExtensionSpec.URI and ext.params
1077
+ ),
1078
+ None,
1079
+ )
1080
+
1081
+ if not input:
1082
+ if interaction_mode not in {InteractionMode.MULTI_TURN, InteractionMode.SINGLE_TURN}:
1083
+ err_console.error(
1084
+ f"Agent {agent.name} does not use any supported UIs.\n"
1085
+ + "Please use the agent according to the following examples and schema:"
1086
+ )
1087
+ err_console.print(_render_examples(agent))
1088
+ exit(1)
1089
+
1090
+ initial_form_render = next(
1091
+ (
1092
+ FormRender.model_validate(ext.params["form_demands"]["initial_form"])
1093
+ for ext in agent.capabilities.extensions or ()
1094
+ if ext.uri == FormServiceExtensionSpec.URI and ext.params
1095
+ ),
1096
+ None,
1097
+ )
1098
+
1099
+ if interaction_mode == InteractionMode.MULTI_TURN:
1100
+ console.print(f"{user_greeting}\n")
1101
+ settings_input = await _ask_settings_questions(settings_render) if settings_render else None
1102
+ turn_input = await _ask_form_questions(initial_form_render) if initial_form_render else handle_input()
1103
+ async with a2a_client(provider.agent_card, context_token=context_token) as client:
1104
+ while True:
1105
+ console.print()
1106
+ await _run_agent(
1107
+ client,
1108
+ input=turn_input,
1109
+ agent_card=agent,
1110
+ context_token=context_token,
1111
+ settings=settings_input,
1112
+ dump_files_path=dump_files,
1113
+ handle_input=handle_input,
1114
+ )
1115
+ console.print()
1116
+ turn_input = handle_input()
1117
+ elif interaction_mode == InteractionMode.SINGLE_TURN:
1118
+ user_greeting = ui_annotations.get("user_greeting", None) or "Enter your instructions."
1119
+ console.print(f"{user_greeting}\n")
1120
+ settings_input = await _ask_settings_questions(settings_render) if settings_render else None
1121
+ console.print()
1122
+ async with a2a_client(provider.agent_card, context_token=context_token) as client:
1123
+ await _run_agent(
1124
+ client,
1125
+ input=await _ask_form_questions(initial_form_render) if initial_form_render else handle_input(),
1126
+ agent_card=agent,
1127
+ context_token=context_token,
1128
+ settings=settings_input,
1129
+ dump_files_path=dump_files,
1130
+ handle_input=handle_input,
1131
+ )
1132
+
1133
+ else:
1134
+ settings_input = await _ask_settings_questions(settings_render) if settings_render else None
1135
+
1136
+ async with a2a_client(provider.agent_card, context_token=context_token) as client:
1137
+ await _run_agent(
1138
+ client,
1139
+ input,
1140
+ agent_card=agent,
1141
+ context_token=context_token,
1142
+ settings=settings_input,
1143
+ dump_files_path=dump_files,
1144
+ handle_input=handle_input,
1145
+ )
1146
+
1147
+
1148
+ @app.command("list")
1149
+ async def list_agents():
1150
+ """List agents."""
1151
+ announce_server_action("Listing agents on")
1152
+ async with configuration.use_platform_client():
1153
+ providers = await Provider.list()
1154
+ max_provider_len = max(len(ProviderUtils.short_location(p)) for p in providers) if providers else 0
1155
+
1156
+ def _sort_fn(provider: Provider):
1157
+ state = {"missing": "1"}
1158
+ return (
1159
+ str(state.get(provider.state, 0)) + f"_{provider.agent_card.name}"
1160
+ if provider.registry
1161
+ else provider.agent_card.name
1162
+ )
1163
+
1164
+ with create_table(
1165
+ Column("Short ID", style="yellow"),
1166
+ Column("Name", style="yellow"),
1167
+ Column("State"),
1168
+ Column("Location", max_width=min(max(max_provider_len, len("Location")), 70)),
1169
+ Column("Info", ratio=2),
1170
+ no_wrap=True,
1171
+ ) as table:
1172
+ for provider in sorted(providers, key=_sort_fn):
1173
+ table.add_row(
1174
+ provider.id[:8],
1175
+ provider.agent_card.name,
1176
+ {
1177
+ "running": "[green]▶ running[/green]",
1178
+ "online": "[green]● connected[/green]",
1179
+ "ready": "[green]● idle[/green]",
1180
+ "starting": "[yellow]✱ starting[/yellow]",
1181
+ "missing": "[bright_black]○ not started[/bright_black]",
1182
+ "offline": "[bright_black]○ disconnected[/bright_black]",
1183
+ "error": "[red]✘ error[/red]",
1184
+ }.get(provider.state, provider.state or "<unknown>"),
1185
+ ProviderUtils.short_location(provider) or "<none>",
1186
+ (
1187
+ f"Error: {error}"
1188
+ if provider.state == "error" and (error := ProviderUtils.last_error(provider))
1189
+ else f"Missing ENV: {{{', '.join(missing_env)}}}"
1190
+ if (missing_env := [var.name for var in provider.missing_configuration])
1191
+ else "<none>"
1192
+ ),
1193
+ )
1194
+ console.print(table)
1195
+
1196
+
1197
+ def _render_schema(schema: dict[str, Any] | None):
1198
+ return "No schema provided." if not schema else rich.json.JSON.from_data(schema)
1199
+
1200
+
1201
+ def _render_examples(agent: AgentCard):
1202
+ # TODO
1203
+ return Text()
1204
+ # md = "## Examples"
1205
+ # for i, example in enumerate(examples):
1206
+ # processing_steps = "\n".join(
1207
+ # f"{i + 1}. {step}" for i, step in enumerate(example.get("processing_steps", []) or [])
1208
+ # )
1209
+ # name = example.get("name", None) or f"Example #{i + 1}"
1210
+ # output = f"""
1211
+ # ### Output
1212
+ # ```
1213
+ # {example.get("output", "")}
1214
+ # ```
1215
+ # """
1216
+ # md += f"""
1217
+ # ### {name}
1218
+ # {example.get("description", None) or ""}
1219
+ #
1220
+ # #### Command
1221
+ # ```sh
1222
+ # {example["command"]}
1223
+ # ```
1224
+ # {output if example.get("output", None) else ""}
1225
+ #
1226
+ # #### Processing steps
1227
+ # {processing_steps}
1228
+ # """
1229
+ # return Markdown(md)
1230
+
1231
+
1232
+ @app.command("info")
1233
+ async def agent_detail(
1234
+ search_path: typing.Annotated[
1235
+ str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
1236
+ ],
1237
+ ):
1238
+ """Show agent details."""
1239
+ announce_server_action(f"Showing agent details for '{search_path}' on")
1240
+ async with configuration.use_platform_client():
1241
+ provider = select_provider(search_path, await Provider.list())
1242
+ agent = provider.agent_card
1243
+
1244
+ basic_info = f"# {agent.name}\n{agent.description}"
1245
+
1246
+ console.print(Markdown(basic_info), "")
1247
+ console.print(Markdown("## Skills"))
1248
+ console.print()
1249
+ for skill in agent.skills:
1250
+ console.print(Markdown(f"**{skill.name}** \n{skill.description}"))
1251
+
1252
+ console.print(_render_examples(agent))
1253
+
1254
+ with create_table(Column("Key", ratio=1), Column("Value", ratio=5), title="Extra information") as table:
1255
+ for key, value in agent.model_dump(exclude={"description", "examples"}).items():
1256
+ if value:
1257
+ table.add_row(key, str(value))
1258
+ console.print()
1259
+ console.print(table)
1260
+
1261
+ with create_table(Column("Key", ratio=1), Column("Value", ratio=5), title="Provider") as table:
1262
+ for key, value in provider.model_dump(exclude={"image_id", "manifest", "source", "registry"}).items():
1263
+ table.add_row(key, str(value))
1264
+ console.print()
1265
+ console.print(table)
1266
+
1267
+
1268
+ env_app = AsyncTyper()
1269
+ app.add_typer(env_app, name="env")
1270
+
1271
+
1272
+ async def _list_env(provider: Provider):
1273
+ async with configuration.use_platform_client():
1274
+ variables = await provider.list_variables()
1275
+ with create_table(Column("name", style="yellow"), Column("value", ratio=1)) as table:
1276
+ for name, value in sorted(variables.items()):
1277
+ table.add_row(name, value)
1278
+ console.print(table)
1279
+
1280
+
1281
+ @env_app.command("add")
1282
+ async def add_env(
1283
+ search_path: typing.Annotated[
1284
+ str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
1285
+ ],
1286
+ env: typing.Annotated[list[str], typer.Argument(help="Environment variables to pass to agent")],
1287
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
1288
+ ) -> None:
1289
+ """Store environment variables. [Admin only]"""
1290
+ url = announce_server_action(f"Adding environment variables for '{search_path}' on")
1291
+ await confirm_server_action("Apply these environment variable changes on", url=url, yes=yes)
1292
+ env_vars = dict(parse_env_var(var) for var in env)
1293
+ async with configuration.use_platform_client():
1294
+ provider = select_provider(search_path, await Provider.list())
1295
+ await provider.update_variables(variables=env_vars)
1296
+ await _list_env(provider)
1297
+
1298
+
1299
+ @env_app.command("list")
1300
+ async def list_env(
1301
+ search_path: typing.Annotated[
1302
+ str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
1303
+ ],
1304
+ ):
1305
+ """List stored environment variables. [Admin only]"""
1306
+ announce_server_action(f"Listing environment variables for '{search_path}' on")
1307
+ async with configuration.use_platform_client():
1308
+ provider = select_provider(search_path, await Provider.list())
1309
+ await _list_env(provider)
1310
+
1311
+
1312
+ @env_app.command("remove")
1313
+ async def remove_env(
1314
+ search_path: typing.Annotated[
1315
+ str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
1316
+ ],
1317
+ env: typing.Annotated[list[str], typer.Argument(help="Environment variable(s) to remove")],
1318
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
1319
+ ):
1320
+ """Remove environment variable(s). [Admin only]"""
1321
+ url = announce_server_action(f"Removing environment variables from '{search_path}' on")
1322
+ await confirm_server_action("Remove the selected environment variables on", url=url, yes=yes)
1323
+ async with configuration.use_platform_client():
1324
+ provider = select_provider(search_path, await Provider.list())
1325
+ await provider.update_variables(variables=dict.fromkeys(env))
1326
+ await _list_env(provider)
1327
+
1328
+
1329
+ feedback_app = AsyncTyper()
1330
+ app.add_typer(feedback_app, name="feedback", help="Manage user feedback for your agents", no_args_is_help=True)
1331
+
1332
+
1333
+ @feedback_app.command("list")
1334
+ async def list_feedback(
1335
+ search_path: typing.Annotated[
1336
+ str | None, typer.Argument(help="Short ID, agent name or part of the provider location")
1337
+ ] = None,
1338
+ limit: typing.Annotated[int, typer.Option("--limit", help="Number of results per page [default: 50]")] = 50,
1339
+ after_cursor: typing.Annotated[str | None, typer.Option("--after", help="Cursor for pagination")] = None,
1340
+ ):
1341
+ """List your agent feedback. [Admin only]"""
1342
+
1343
+ announce_server_action("Listing feedback on")
1344
+
1345
+ provider_id = None
1346
+
1347
+ async with configuration.use_platform_client():
1348
+ if search_path:
1349
+ providers = await Provider.list()
1350
+ provider = select_provider(search_path, providers)
1351
+ provider_id = str(provider.id)
1352
+
1353
+ response = await UserFeedback.list(
1354
+ provider_id=provider_id,
1355
+ limit=limit,
1356
+ after_cursor=after_cursor,
1357
+ )
1358
+
1359
+ if not response.items:
1360
+ console.print("No feedback found.")
1361
+ return
1362
+
1363
+ with create_table(
1364
+ Column("Rating", style="yellow", ratio=1),
1365
+ Column("Agent", style="cyan", ratio=2),
1366
+ Column("Task ID", style="dim", ratio=1),
1367
+ Column("Comment", ratio=3),
1368
+ Column("Tags", ratio=2),
1369
+ Column("Date", style="dim", ratio=1),
1370
+ ) as table:
1371
+ for item in response.items:
1372
+ rating_icon = "✓" if item.rating == 1 else "✗"
1373
+ agent_name = item.agent_name or str(item.provider_id)[:8]
1374
+ task_id_short = str(item.task_id)[:8]
1375
+ comment = item.comment or ""
1376
+ if len(comment) > 50:
1377
+ comment = comment[:50] + "..."
1378
+ tags = ", ".join(item.comment_tags or []) if item.comment_tags else "-"
1379
+ created_at = item.created_at.strftime("%Y-%m-%d")
1380
+
1381
+ table.add_row(rating_icon, agent_name, task_id_short, comment, tags, created_at)
1382
+
1383
+ console.print(table)
1384
+ console.print(f"Showing {len(response.items)} of {response.total_count} total feedback entries")
1385
+ if response.has_more and response.next_page_token:
1386
+ console.print(f"Use --after {response.next_page_token} to see more")