Skip to content

Commit 2cd3d31

Browse files
authored
feat(server): agent build actions, agent origin tracking (#1307)
Signed-off-by: Radek Ježek <[email protected]>
1 parent 993661b commit 2cd3d31

File tree

38 files changed

+1470
-380
lines changed

38 files changed

+1470
-380
lines changed

apps/beeai-cli/src/beeai_cli/commands/build.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import typer
1717
from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
1818
from anyio import open_process
19+
from beeai_sdk.platform import AddProvider
1920
from beeai_sdk.platform.provider_build import BuildState, ProviderBuild
2021
from httpx import AsyncClient, HTTPError
2122
from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, wait_fixed
@@ -136,18 +137,21 @@ async def server_side_build_experimental(
136137
github_url: typing.Annotated[
137138
str, typer.Argument(..., help="Github repository URL (public or private if supported by the platform instance)")
138139
],
140+
add: typing.Annotated[bool, typer.Option(help="Add agent to the platform after build")] = False,
139141
):
140142
"""EXPERIMENTAL: Build agent from github repository in the platform."""
141143
from beeai_cli.configuration import Configuration
142144

143145
async with Configuration().use_platform_client():
144-
build = await ProviderBuild.create(location=github_url)
146+
build = await ProviderBuild.create(location=github_url, on_complete=AddProvider() if add else None)
145147
async for message in build.stream_logs():
146148
print_log(message, ansi_mode=True)
147149
build = await build.get()
148150
if build.status == BuildState.COMPLETED:
149-
console.success(
150-
f"Agent built successfully, add it to the platform using: [bold]beeai add {build.destination}[/bold]"
151-
)
151+
if add:
152+
message = "Agent added successfully. List agents using [green]beeai list[/green]"
153+
else:
154+
message = f"Agent built successfully, add it to the platform using: [green]beeai add {build.destination}[/green]"
155+
console.success(message)
152156
else:
153157
console.error("Agent build failed, see logs above for details.")

apps/beeai-sdk/src/beeai_sdk/a2a/extensions/ui/agent_detail.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ class AgentDetailContributor(pydantic.BaseModel):
2222
url: str | None = None
2323

2424

25+
class EnvVar(pydantic.BaseModel):
26+
name: str
27+
description: str | None = None
28+
required: bool = False
29+
30+
2531
class AgentDetail(pydantic.BaseModel, extra="allow"):
2632
interaction_mode: str | None = pydantic.Field("multi-turn", examples=["multi-turn", "single-turn"])
2733
user_greeting: str | None = None
@@ -35,6 +41,7 @@ class AgentDetail(pydantic.BaseModel, extra="allow"):
3541
container_image_url: str | None = None
3642
author: AgentDetailContributor | None = None
3743
contributors: list[AgentDetailContributor] | None = None
44+
variables: list[EnvVar] | None = None
3845

3946

4047
class AgentDetailExtensionSpec(BaseExtensionSpec[AgentDetail]):

apps/beeai-sdk/src/beeai_sdk/platform/provider.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from a2a.types import AgentCard
1313

1414
from beeai_sdk.platform.client import PlatformClient, get_platform_client
15-
from beeai_sdk.util.utils import parse_stream
15+
from beeai_sdk.util.utils import filter_dict, parse_stream
1616

1717

1818
class ProviderErrorMessage(pydantic.BaseModel):
@@ -27,11 +27,13 @@ class EnvVar(pydantic.BaseModel):
2727

2828
class Provider(pydantic.BaseModel):
2929
id: str
30-
auto_stop_timeout: timedelta | None = None
30+
auto_stop_timeout: timedelta
3131
source: str
32+
origin: str
3233
registry: str | None = None
3334
auto_remove: bool = False
3435
created_at: pydantic.AwareDatetime
36+
updated_at: pydantic.AwareDatetime
3537
last_active_at: pydantic.AwareDatetime
3638
agent_card: AgentCard
3739
state: typing.Literal["missing", "starting", "ready", "running", "error"] = "missing"
@@ -45,17 +47,66 @@ async def create(
4547
location: str,
4648
agent_card: AgentCard | None = None,
4749
auto_remove: bool = False,
50+
origin: str | None = None,
51+
auto_stop_timeout: timedelta | None = None,
52+
variables: dict[str, str] | None = None,
4853
client: PlatformClient | None = None,
4954
) -> "Provider":
55+
auto_stop_timeout_sec = auto_stop_timeout.total_seconds() if auto_stop_timeout is not None else None
56+
5057
async with client or get_platform_client() as client:
5158
return pydantic.TypeAdapter(Provider).validate_python(
5259
(
5360
await client.post(
5461
url="/api/v1/providers",
55-
json={
56-
"location": location,
57-
"agent_card": agent_card.model_dump(mode="json") if agent_card else None,
58-
},
62+
json=filter_dict(
63+
{
64+
"location": location,
65+
"agent_card": agent_card.model_dump(mode="json") if agent_card else None,
66+
"origin": origin,
67+
"variables": variables,
68+
"auto_stop_timeout_sec": auto_stop_timeout_sec,
69+
}
70+
),
71+
params={"auto_remove": auto_remove},
72+
)
73+
)
74+
.raise_for_status()
75+
.json()
76+
)
77+
78+
async def patch(
79+
self: "Provider | str",
80+
*,
81+
location: str | None = None,
82+
agent_card: AgentCard | None = None,
83+
auto_remove: bool = False,
84+
origin: str | None = None,
85+
auto_stop_timeout: timedelta | None = None,
86+
variables: dict[str, str] | None = None,
87+
client: PlatformClient | None = None,
88+
) -> "Provider":
89+
# `self` has a weird type so that you can call both `instance.patch()` to update an instance, or `Provider.patch("123", ...)` to update a provider
90+
91+
provider_id = self if isinstance(self, str) else self.id
92+
payload = filter_dict(
93+
{
94+
"location": location,
95+
"agent_card": agent_card.model_dump(mode="json") if agent_card else None,
96+
"variables": variables,
97+
"auto_stop_timeout_sec": None if auto_stop_timeout is None else auto_stop_timeout.total_seconds(),
98+
"origin": origin,
99+
}
100+
)
101+
if not payload:
102+
return await Provider.get(self)
103+
104+
async with client or get_platform_client() as client:
105+
return pydantic.TypeAdapter(Provider).validate_python(
106+
(
107+
await client.patch(
108+
url=f"/api/v1/providers/{provider_id}",
109+
json=payload,
59110
params={"auto_remove": auto_remove},
60111
)
61112
)

apps/beeai-sdk/src/beeai_sdk/platform/provider_build.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from collections.abc import AsyncIterator
66
from datetime import timedelta
77
from enum import StrEnum
8-
from typing import Any, Literal
8+
from typing import Any, Literal, TypeAlias
9+
from uuid import UUID
910

1011
import pydantic
1112

@@ -17,23 +18,83 @@
1718
class BuildState(StrEnum):
1819
MISSING = "missing"
1920
IN_PROGRESS = "in_progress"
21+
BUILD_COMPLETED = "build_completed"
2022
COMPLETED = "completed"
2123
FAILED = "failed"
2224

2325

26+
class AddProvider(pydantic.BaseModel):
27+
"""
28+
Will add a new provider or update an existing one with the same base docker image ID
29+
(docker registry + repository, excluding tag)
30+
"""
31+
32+
type: Literal["add_provider"] = "add_provider"
33+
auto_stop_timeout_sec: int | None = pydantic.Field(
34+
default=None,
35+
gt=0,
36+
le=600,
37+
description=(
38+
"Timeout after which the agent provider will be automatically downscaled if unused."
39+
"Contact administrator if you need to increase this value."
40+
),
41+
)
42+
variables: dict[str, str] | None = None
43+
44+
45+
class UpdateProvider(pydantic.BaseModel):
46+
"""Will update provider specified by ID"""
47+
48+
type: Literal["update_provider"] = "update_provider"
49+
provider_id: UUID
50+
51+
52+
class NoAction(pydantic.BaseModel):
53+
type: Literal["no_action"] = "no_action"
54+
55+
56+
OnCompleteAction: TypeAlias = AddProvider | UpdateProvider | NoAction
57+
58+
2459
class ProviderBuild(pydantic.BaseModel):
2560
id: str
2661
created_at: pydantic.AwareDatetime
2762
status: BuildState
2863
source: ResolvedGithubUrl
2964
destination: str
3065
created_by: str
66+
error_message: str | None = None
67+
68+
@staticmethod
69+
async def create(
70+
*, location: str, client: PlatformClient | None = None, on_complete: OnCompleteAction | None = None
71+
) -> ProviderBuild:
72+
on_complete = on_complete or NoAction()
73+
async with client or get_platform_client() as client:
74+
return pydantic.TypeAdapter(ProviderBuild).validate_python(
75+
(
76+
await client.post(
77+
url="/api/v1/provider_builds",
78+
json={"location": location, "on_complete": on_complete.model_dump(exclude_none=True)},
79+
)
80+
)
81+
.raise_for_status()
82+
.json()
83+
)
3184

3285
@staticmethod
33-
async def create(*, location: str, client: PlatformClient | None = None) -> ProviderBuild:
86+
async def preview(
87+
*, location: str, client: PlatformClient | None = None, on_complete: OnCompleteAction | None = None
88+
) -> ProviderBuild:
89+
on_complete = on_complete or NoAction()
3490
async with client or get_platform_client() as client:
3591
return pydantic.TypeAdapter(ProviderBuild).validate_python(
36-
(await client.post(url="/api/v1/provider_builds", json={"location": location}))
92+
(
93+
await client.post(
94+
url="/api/v1/provider_builds/preview",
95+
json={"location": location, "on_complete": on_complete.model_dump(exclude_none=True)},
96+
)
97+
)
3798
.raise_for_status()
3899
.json()
39100
)

apps/beeai-sdk/src/beeai_sdk/util/utils.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
33
import json
44
import re
55
from collections.abc import AsyncIterator
6-
from typing import Any, TypeVar
6+
from typing import Any, TypeVar, cast
77

88
import httpx
99
from httpx import HTTPStatusError
1010

11+
T = TypeVar("T")
1112
V = TypeVar("V")
1213

1314

14-
def filter_dict(map: dict[str, V], value_to_exclude: V = None) -> dict[str, V]:
15+
def filter_dict(map: dict[str, T | V], value_to_exclude: V = None) -> dict[str, T]:
1516
"""Remove entries with unwanted values (None by default) from dictionary."""
16-
return {filter: value for filter, value in map.items() if value is not value_to_exclude}
17+
return {key: cast(T, value) for key, value in map.items() if value is not value_to_exclude}
1718

1819

1920
async def parse_stream(response: httpx.Response) -> AsyncIterator[dict[str, Any]]:

apps/beeai-server/src/beeai_server/api/routes/provider_builds.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,19 @@ async def create_provider_build(
2929
request: CreateProviderBuildRequest,
3030
provider_build_service: ProviderBuildServiceDependency,
3131
) -> ProviderBuild:
32-
return await provider_build_service.create_build(location=request.location, user=user.user)
32+
return await provider_build_service.create_build(
33+
location=request.location, user=user.user, on_complete=request.on_complete
34+
)
35+
36+
@router.post("/preview")
37+
async def preview_provider_build(
38+
user: Annotated[AuthorizedUser, Depends(RequiresPermissions(provider_builds={"write"}))],
39+
request: CreateProviderBuildRequest,
40+
provider_build_service: ProviderBuildServiceDependency,
41+
) -> ProviderBuild:
42+
return await provider_build_service.preview_build(
43+
location=request.location, user=user.user, on_complete=request.on_complete
44+
)
3345

3446
@router.get("/{id}")
3547
async def get_provider_build(

apps/beeai-server/src/beeai_server/api/routes/providers.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
22
# SPDX-License-Identifier: Apache-2.0
3-
from datetime import timedelta
43
from typing import Annotated
54
from uuid import UUID
65

@@ -16,8 +15,9 @@
1615
RequiresPermissions,
1716
)
1817
from beeai_server.api.routes.a2a import create_proxy_agent_card
18+
from beeai_server.api.schema.common import EntityModel
1919
from beeai_server.api.schema.env import ListVariablesSchema, UpdateVariablesRequest
20-
from beeai_server.api.schema.provider import CreateProviderRequest
20+
from beeai_server.api.schema.provider import CreateProviderRequest, PatchProviderRequest
2121
from beeai_server.domain.models.common import PaginatedResult
2222
from beeai_server.domain.models.permissions import AuthorizedUser
2323
from beeai_server.domain.models.provider import ProviderWithState
@@ -38,14 +38,33 @@ async def create_provider(
3838
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Auto remove functionality is disabled")
3939
return await provider_service.create_provider(
4040
user=user.user,
41-
auto_stop_timeout=timedelta(seconds=request.auto_stop_timeout_sec),
41+
auto_stop_timeout=request.auto_stop_timeout,
4242
location=request.location,
43+
origin=request.origin,
4344
agent_card=request.agent_card,
4445
auto_remove=auto_remove,
4546
variables=request.variables,
4647
)
4748

4849

50+
@router.patch("/{id}")
51+
async def patch_provider(
52+
id: UUID,
53+
user: Annotated[AuthorizedUser, Depends(RequiresPermissions(providers={"write"}))],
54+
request: PatchProviderRequest,
55+
provider_service: ProviderServiceDependency,
56+
) -> ProviderWithState:
57+
return await provider_service.patch_provider(
58+
provider_id=id,
59+
user=user.user,
60+
auto_stop_timeout=request.auto_stop_timeout,
61+
location=request.location,
62+
origin=request.origin,
63+
agent_card=request.agent_card,
64+
variables=request.variables,
65+
)
66+
67+
4968
@router.post("/preview")
5069
async def preview_provider(
5170
request: CreateProviderRequest,
@@ -61,15 +80,16 @@ async def list_providers(
6180
request: Request,
6281
user: Annotated[AuthorizedUser, Depends(RequiresPermissions(providers={"read"}), use_cache=False)],
6382
user_owned: Annotated[bool, Query()] = False,
64-
) -> PaginatedResult[ProviderWithState]:
83+
origin: Annotated[str | None, Query()] = None,
84+
) -> PaginatedResult[EntityModel[ProviderWithState]]:
6585
providers = []
66-
for provider in await provider_service.list_providers(user=user.user if user_owned else None):
86+
for provider in await provider_service.list_providers(user=user.user if user_owned else None, origin=origin):
6787
new_provider = provider.model_copy(
6888
update={
6989
"agent_card": create_proxy_agent_card(provider.agent_card, provider_id=provider.id, request=request)
7090
}
7191
)
72-
providers.append(new_provider)
92+
providers.append(EntityModel(new_provider))
7393

7494
return PaginatedResult(items=providers, total_count=len(providers))
7595

@@ -80,10 +100,14 @@ async def get_provider(
80100
provider_service: ProviderServiceDependency,
81101
request: Request,
82102
_: Annotated[AuthorizedUser, Depends(RequiresPermissions(providers={"read"}))],
83-
) -> ProviderWithState:
103+
) -> EntityModel[ProviderWithState]:
84104
provider = await provider_service.get_provider(provider_id=id)
85-
return provider.model_copy(
86-
update={"agent_card": create_proxy_agent_card(provider.agent_card, provider_id=provider.id, request=request)}
105+
return EntityModel(
106+
provider.model_copy(
107+
update={
108+
"agent_card": create_proxy_agent_card(provider.agent_card, provider_id=provider.id, request=request)
109+
}
110+
)
87111
)
88112

89113

apps/beeai-server/src/beeai_server/api/schema/common.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77

88

99
class PaginationQuery(BaseModel):
10-
limit: int = Field(default=40, ge=1, le=100)
10+
limit: int = Field(default_factory=lambda: 40, ge=1, le=100)
1111
page_token: UUID | None = None
12-
order: str = Field(default="desc", pattern="^(asc|desc)$")
13-
order_by: str = Field(default="created_at", pattern="^created_at|updated_at$")
12+
order: str = Field(default_factory=lambda: "desc", pattern="^(asc|desc)$")
13+
order_by: str = Field(default_factory=lambda: "created_at", pattern="^created_at|updated_at$")
1414

1515

1616
class ErrorStreamResponseError(BaseModel, extra="allow"):

0 commit comments

Comments
 (0)