Skip to content

Commit ec30624

Browse files
authored
Feat/1714 display agents feedback in cli (#1732)
* docs: better description of connectors Signed-off-by: Tomas Weiss <[email protected]> * feat: user feedback basic model and cli integration Signed-off-by: Tomas Weiss <[email protected]> * feat: cursor based pagination Signed-off-by: Tomas Weiss <[email protected]> * fix: better help Signed-off-by: Tomas Weiss <[email protected]> * docs: agentstack feedback list cli reference Signed-off-by: Tomas Weiss <[email protected]> * chore: code cleanup Signed-off-by: Tomas Weiss <[email protected]> * chore: provide agent name from backend Signed-off-by: Tomas Weiss <[email protected]> * chore: code review comments Signed-off-by: Tomas Weiss <[email protected]> * fix: code review comments Signed-off-by: Tomas Weiss <[email protected]> * fix: code review improvements Signed-off-by: Tomas Weiss <[email protected]> --------- Signed-off-by: Tomas Weiss <[email protected]>
1 parent aaf8312 commit ec30624

File tree

13 files changed

+256
-7
lines changed

13 files changed

+256
-7
lines changed

apps/agentstack-cli/src/agentstack_cli/commands/agent.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
TextField,
6262
TextFieldValue,
6363
)
64-
from agentstack_sdk.platform import BuildState, ModelProvider, Provider
64+
from agentstack_sdk.platform import BuildState, ModelProvider, Provider, UserFeedback
6565
from agentstack_sdk.platform.context import Context, ContextPermissions, ContextToken, Permissions
6666
from agentstack_sdk.platform.model_provider import ModelCapability
6767
from InquirerPy import inquirer
@@ -1161,3 +1161,63 @@ async def remove_env(
11611161
provider = select_provider(search_path, await Provider.list())
11621162
await provider.update_variables(variables=dict.fromkeys(env))
11631163
await _list_env(provider)
1164+
1165+
1166+
feedback_app = AsyncTyper()
1167+
app.add_typer(feedback_app, name="feedback", help="Manage user feedback for your agents", no_args_is_help=True)
1168+
1169+
1170+
@feedback_app.command("list")
1171+
async def list_feedback(
1172+
search_path: typing.Annotated[
1173+
str | None, typer.Argument(help="Short ID, agent name or part of the provider location")
1174+
] = None,
1175+
limit: typing.Annotated[int, typer.Option("--limit", help="Number of results per page [default: 50]")] = 50,
1176+
after_cursor: typing.Annotated[str | None, typer.Option("--after", help="Cursor for pagination")] = None,
1177+
):
1178+
"""List your agent feedback"""
1179+
1180+
announce_server_action("Listing feedback on")
1181+
1182+
provider_id = None
1183+
1184+
async with configuration.use_platform_client():
1185+
if search_path:
1186+
providers = await Provider.list()
1187+
provider = select_provider(search_path, providers)
1188+
provider_id = str(provider.id)
1189+
1190+
response = await UserFeedback.list(
1191+
provider_id=provider_id,
1192+
limit=limit,
1193+
after_cursor=after_cursor,
1194+
)
1195+
1196+
if not response.items:
1197+
console.print("No feedback found.")
1198+
return
1199+
1200+
with create_table(
1201+
Column("Rating", style="yellow", ratio=1),
1202+
Column("Agent", style="cyan", ratio=2),
1203+
Column("Task ID", style="dim", ratio=1),
1204+
Column("Comment", ratio=3),
1205+
Column("Tags", ratio=2),
1206+
Column("Date", style="dim", ratio=1),
1207+
) as table:
1208+
for item in response.items:
1209+
rating_icon = "✓" if item.rating == 1 else "✗"
1210+
agent_name = item.agent_name or str(item.provider_id)[:8]
1211+
task_id_short = str(item.task_id)[:8]
1212+
comment = item.comment or ""
1213+
if len(comment) > 50:
1214+
comment = comment[:50] + "..."
1215+
tags = ", ".join(item.comment_tags or []) if item.comment_tags else "-"
1216+
created_at = item.created_at.strftime("%Y-%m-%d")
1217+
1218+
table.add_row(rating_icon, agent_name, task_id_short, comment, tags, created_at)
1219+
1220+
console.print(table)
1221+
console.print(f"Showing {len(response.items)} of {response.total_count} total feedback entries")
1222+
if response.has_more and response.next_page_token:
1223+
console.print(f"Use --after {response.next_page_token} to see more")

apps/agentstack-sdk-py/src/agentstack_sdk/platform/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
from .model_provider import *
88
from .provider import *
99
from .provider_build import *
10+
from .user_feedback import *
1011
from .vector_store import *
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from datetime import datetime
5+
from uuid import UUID
6+
7+
import pydantic
8+
9+
from agentstack_sdk.platform.client import PlatformClient, get_platform_client
10+
from agentstack_sdk.platform.common import PaginatedResult
11+
from agentstack_sdk.util.utils import filter_dict
12+
13+
14+
class UserFeedback(pydantic.BaseModel):
15+
id: UUID
16+
provider_id: UUID
17+
task_id: UUID
18+
context_id: UUID
19+
rating: int
20+
message: str
21+
comment: str | None = None
22+
comment_tags: list[str] | None = None
23+
created_at: datetime
24+
agent_name: str
25+
26+
@staticmethod
27+
async def list(
28+
*,
29+
provider_id: str | None = None,
30+
limit: int = 50,
31+
after_cursor: str | None = None,
32+
client: PlatformClient | None = None,
33+
) -> "ListUserFeedbackResponse":
34+
async with client or get_platform_client() as client:
35+
params = filter_dict({"provider_id": provider_id, "limit": limit, "after_cursor": after_cursor})
36+
return pydantic.TypeAdapter(ListUserFeedbackResponse).validate_python(
37+
(await client.get(url="/api/v1/user_feedback", params=params)).raise_for_status().json()
38+
)
39+
40+
41+
class ListUserFeedbackResponse(PaginatedResult[UserFeedback]):
42+
pass

apps/agentstack-server/src/agentstack_server/api/auth/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
}
5757
ROLE_PERMISSIONS[UserRole.DEVELOPER] = ROLE_PERMISSIONS[UserRole.USER] | Permissions(
5858
providers={"read", "write"},
59+
feedback={"read", "write"},
5960
provider_builds={"read", "write"},
6061
provider_variables={"read", "write"},
6162
mcp_providers={"read", "write"},

apps/agentstack-server/src/agentstack_server/api/routes/user_feedback.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
from typing import Annotated
5+
from uuid import UUID
56

67
import fastapi
7-
from fastapi import Depends
8+
from fastapi import Depends, Query
89

910
from agentstack_server.api.dependencies import (
1011
RequiresPermissions,
1112
UserFeedbackServiceDependency,
1213
)
13-
from agentstack_server.api.schema.user_feedback import InsertUserFeedbackRequest
14+
from agentstack_server.api.schema.user_feedback import (
15+
InsertUserFeedbackRequest,
16+
ListUserFeedbackResponse,
17+
UserFeedbackResponse,
18+
)
1419
from agentstack_server.domain.models.permissions import AuthorizedUser
1520

1621
router = fastapi.APIRouter()
@@ -32,3 +37,24 @@ async def user_feedback(
3237
message=request.message,
3338
user=user.user,
3439
)
40+
41+
42+
@router.get("", status_code=fastapi.status.HTTP_200_OK)
43+
async def list_user_feedback(
44+
user_feedback_service: UserFeedbackServiceDependency,
45+
user: Annotated[AuthorizedUser, Depends(RequiresPermissions(feedback={"read"}))],
46+
provider_id: Annotated[UUID | None, Query()] = None,
47+
limit: Annotated[int, Query(ge=1, le=100)] = 50,
48+
after_cursor: Annotated[UUID | None, Query()] = None,
49+
) -> ListUserFeedbackResponse:
50+
feedback_list, total, has_more = await user_feedback_service.list_user_feedback(
51+
user=user.user,
52+
provider_id=provider_id,
53+
limit=limit,
54+
after_cursor=after_cursor,
55+
)
56+
return ListUserFeedbackResponse(
57+
items=[UserFeedbackResponse.model_validate(dict(feedback)) for feedback in feedback_list],
58+
total_count=total,
59+
has_more=has_more,
60+
)

apps/agentstack-server/src/agentstack_server/api/schema/user_feedback.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
22
# SPDX-License-Identifier: Apache-2.0
33

4+
from datetime import datetime
45
from uuid import UUID
56

67
from pydantic import BaseModel, Field, field_validator
78

9+
from agentstack_server.domain.models.common import PaginatedResult
10+
811

912
class InsertUserFeedbackRequest(BaseModel):
1013
"""Request to create a user feedback."""
@@ -23,3 +26,19 @@ def validate_rating(cls, v):
2326
if v not in [1, -1]:
2427
raise ValueError("Rating must be either 1 or -1")
2528
return v
29+
30+
31+
class UserFeedbackResponse(BaseModel):
32+
id: UUID
33+
provider_id: UUID
34+
task_id: UUID
35+
context_id: UUID
36+
rating: int
37+
message: str
38+
comment: str | None = None
39+
comment_tags: list[str] | None = None
40+
created_at: datetime
41+
agent_name: str
42+
43+
44+
ListUserFeedbackResponse = PaginatedResult[UserFeedbackResponse]

apps/agentstack-server/src/agentstack_server/domain/models/permissions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Permissions(BaseModel):
2121
system_configuration: SerializeAsAny[set[Literal["read", "write", "*"]]] = set()
2222

2323
files: SerializeAsAny[set[Literal["read", "write", "extract", "*"]]] = set()
24-
feedback: SerializeAsAny[set[Literal["write"]]] = set()
24+
feedback: SerializeAsAny[set[Literal["read", "write"]]] = set()
2525
vector_stores: SerializeAsAny[set[Literal["read", "write", "*"]]] = set()
2626
variables: SerializeAsAny[set[Literal["read", "write", "*"]]] = set()
2727

apps/agentstack-server/src/agentstack_server/domain/models/user_feedback.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class UserFeedback(BaseModel):
2020
created_at: AwareDatetime = Field(default_factory=utc_now)
2121
created_by: UUID
2222
trace_id: str | None
23+
agent_name: str | None = None
2324

2425
@field_validator("rating")
2526
@classmethod

apps/agentstack-server/src/agentstack_server/domain/repositories/user_feedback.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
from typing import Protocol, runtime_checkable
5+
from uuid import UUID
56

67
from agentstack_server.domain.models.user_feedback import UserFeedback
78

89

910
@runtime_checkable
1011
class IUserFeedbackRepository(Protocol):
1112
async def create(self, *, user_feedback: UserFeedback) -> None: ...
13+
14+
async def list(
15+
self,
16+
*,
17+
provider_created_by: UUID | None = None,
18+
provider_id: UUID | None = None,
19+
limit: int = 50,
20+
after_cursor: UUID | None = None,
21+
) -> tuple[list[UserFeedback], int, bool]: ...

apps/agentstack-server/src/agentstack_server/infrastructure/persistence/repositories/user_feedback.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
22
# SPDX-License-Identifier: Apache-2.0
33

4+
from uuid import UUID
5+
46
from kink import inject
5-
from sqlalchemy import ARRAY, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Table, Text
7+
from sqlalchemy import ARRAY, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Table, Text, select
68
from sqlalchemy import UUID as SQL_UUID
79
from sqlalchemy.ext.asyncio import AsyncConnection
810

911
from agentstack_server.domain.models.user_feedback import UserFeedback
1012
from agentstack_server.domain.repositories.user_feedback import IUserFeedbackRepository
1113
from agentstack_server.infrastructure.persistence.repositories.db_metadata import metadata
14+
from agentstack_server.infrastructure.persistence.repositories.provider import providers_table
15+
from agentstack_server.infrastructure.persistence.repositories.utils import cursor_paginate
1216

1317
user_feedback_table = Table(
1418
"user_feedback",
@@ -48,3 +52,36 @@ async def create(self, *, user_feedback: UserFeedback) -> None:
4852
created_by=user_feedback.created_by,
4953
)
5054
await self.connection.execute(query)
55+
56+
async def list(
57+
self,
58+
*,
59+
provider_created_by: UUID | None = None,
60+
provider_id: UUID | None = None,
61+
limit: int = 50,
62+
after_cursor: UUID | None = None,
63+
) -> tuple[list[UserFeedback], int, bool]:
64+
query = select(
65+
user_feedback_table,
66+
providers_table.c.agent_card["name"].label("agent_name"),
67+
).join(providers_table, user_feedback_table.c.provider_id == providers_table.c.id)
68+
69+
if provider_created_by is not None:
70+
query = query.where(providers_table.c.created_by == provider_created_by)
71+
72+
if provider_id is not None:
73+
query = query.where(user_feedback_table.c.provider_id == provider_id)
74+
75+
result = await cursor_paginate(
76+
connection=self.connection,
77+
query=query,
78+
order_column=user_feedback_table.c.created_at,
79+
id_column=user_feedback_table.c.id,
80+
limit=limit,
81+
after_cursor=after_cursor,
82+
order="desc",
83+
)
84+
85+
feedback_list = [UserFeedback.model_validate(dict(row._mapping)) for row in result.items]
86+
87+
return feedback_list, result.total_count, result.has_more

0 commit comments

Comments
 (0)