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.
- agentstack_cli/__init__.py +164 -0
- agentstack_cli/api.py +160 -0
- agentstack_cli/async_typer.py +113 -0
- agentstack_cli/auth_manager.py +242 -0
- agentstack_cli/commands/__init__.py +3 -0
- agentstack_cli/commands/agent.py +1386 -0
- agentstack_cli/commands/build.py +222 -0
- agentstack_cli/commands/connector.py +301 -0
- agentstack_cli/commands/model.py +653 -0
- agentstack_cli/commands/platform/__init__.py +198 -0
- agentstack_cli/commands/platform/base_driver.py +217 -0
- agentstack_cli/commands/platform/lima_driver.py +277 -0
- agentstack_cli/commands/platform/wsl_driver.py +229 -0
- agentstack_cli/commands/self.py +213 -0
- agentstack_cli/commands/server.py +315 -0
- agentstack_cli/commands/user.py +87 -0
- agentstack_cli/configuration.py +79 -0
- agentstack_cli/console.py +25 -0
- agentstack_cli/data/.gitignore +2 -0
- agentstack_cli/data/helm-chart.tgz +0 -0
- agentstack_cli/data/lima-guestagent.Linux-aarch64.gz +0 -0
- agentstack_cli/data/limactl +0 -0
- agentstack_cli/utils.py +389 -0
- agentstack_cli-0.6.0rc1.dist-info/METADATA +107 -0
- agentstack_cli-0.6.0rc1.dist-info/RECORD +27 -0
- agentstack_cli-0.6.0rc1.dist-info/WHEEL +4 -0
- agentstack_cli-0.6.0rc1.dist-info/entry_points.txt +4 -0
|
@@ -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")
|