Skip to content
Merged
31 changes: 20 additions & 11 deletions agents/form/src/form/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import os
from typing import Annotated

from pydantic import BaseModel

import a2a.server.agent_execution
import a2a.server.apps
Expand All @@ -20,17 +20,20 @@
from agentstack_sdk.server.context import RunContext

import agentstack_sdk.a2a.extensions
from agentstack_sdk.a2a.extensions.ui.form import (
from agentstack_sdk.a2a.extensions.common.form import (
DateField,
TextField,
FileField,
FileInfo,
CheckboxField,
MultiSelectField,
OptionItem,
FormExtensionServer,
FormExtensionSpec,
FormRender,
)
from agentstack_sdk.a2a.extensions.services.form import (
FormServiceExtensionServer,
FormServiceExtensionSpec,
)

agent_detail_extension_spec = agentstack_sdk.a2a.extensions.AgentDetailExtensionSpec(
params=agentstack_sdk.a2a.extensions.AgentDetail(
Expand Down Expand Up @@ -65,18 +68,24 @@
)

form_render = FormRender(
id="adventure_form",
title="Let’s go on an adventure",
columns=2,
fields=[location, date_from, date_to, notes, flexible, interests],
)
form_extension_spec = FormExtensionSpec(form_render)
form_extension_spec = FormServiceExtensionSpec.demand(initial_form=form_render)

server = Server()

class FormData(BaseModel):
location: str | None
date_from: str | None
date_to: str | None
notes: list[FileInfo] | None
flexible: bool | None
interests: list[str] | None

@server.agent(
name="Single-turn Form Agent",
name="Single-turn Form Agent 2",
documentation_url=f"https://github.com/i-am-bee/agentstack/blob/{os.getenv('RELEASE_VERSION', 'main')}/agents/form",
version="1.0.0",
default_input_modes=["text", "text/plain"],
Expand All @@ -97,17 +106,17 @@
],
)
async def agent(
input: Message,
_message: Message,
form: Annotated[
FormExtensionServer,
FormServiceExtensionServer,
form_extension_spec,
],
):
"""Example demonstrating a single-turn agent using a form to collect user input."""

form_data = form.parse_form_response(message=input)
form_data = form.parse_initial_form(model=FormData)

yield f"Hello {form_data.values['location'].value}"
yield f"Hello {form_data.location}"


def serve():
Expand Down
21 changes: 13 additions & 8 deletions apps/agentstack-cli/src/agentstack_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,21 @@
EmbeddingFulfillment,
EmbeddingServiceExtensionClient,
EmbeddingServiceExtensionSpec,
FormServiceExtensionSpec,
LLMFulfillment,
LLMServiceExtensionClient,
LLMServiceExtensionSpec,
PlatformApiExtensionClient,
PlatformApiExtensionSpec,
RequestFormExtensionSpec,
TrajectoryExtensionClient,
TrajectoryExtensionSpec,
)
from agentstack_sdk.a2a.extensions.ui.form import (
from agentstack_sdk.a2a.extensions.common.form import (
CheckboxField,
CheckboxFieldValue,
DateField,
DateFieldValue,
FormExtensionSpec,
FormFieldValue,
FormRender,
FormResponse,
Expand Down Expand Up @@ -319,7 +320,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
).execute_async()
form_values[field.id] = CheckboxFieldValue(value=answer)
console.print()
return FormResponse(id=form_render.id, values=form_values)
return FormResponse(values=form_values)


async def _run_agent(
Expand Down Expand Up @@ -384,7 +385,11 @@ async def _run_agent(
else {}
)
| (
{FormExtensionSpec.URI: typing.cast(FormResponse, input).model_dump(mode="json")}
{
FormServiceExtensionSpec.URI: {
"form_fulfillments": {"initial_form": typing.cast(FormResponse, input).model_dump(mode="json")}
}
}
if isinstance(input, FormResponse)
else {}
)
Expand Down Expand Up @@ -470,7 +475,7 @@ async def _run_agent(
raise ValueError("Agent requires input but no input handler provided")

if form_metadata := (
message.metadata.get(FormExtensionSpec.URI) if message and message.metadata else None
message.metadata.get(RequestFormExtensionSpec.URI) if message and message.metadata else None
):
stream = client.send_message(
Message(
Expand All @@ -480,7 +485,7 @@ async def _run_agent(
task_id=task_id,
context_id=context_token.context_id,
metadata={
FormExtensionSpec.URI: (
RequestFormExtensionSpec.URI: (
await _ask_form_questions(FormRender.model_validate(form_metadata))
).model_dump(mode="json")
},
Expand Down Expand Up @@ -803,9 +808,9 @@ async def run_agent(

initial_form_render = next(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably refactor this and use the extension client and parse as service extension cc @jezekra1

(
FormRender.model_validate(ext.params)
FormRender.model_validate(ext.params["form_demands"]["initial_form"])
for ext in agent.capabilities.extensions or ()
if ext.uri == FormExtensionSpec.URI and ext.params
if ext.uri == FormServiceExtensionSpec.URI and ext.params
),
None,
)
Expand Down
44 changes: 44 additions & 0 deletions apps/agentstack-sdk-py/examples/form_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
# SPDX-License-Identifier: Apache-2.0
from typing import Annotated

from a2a.types import Message
from pydantic import BaseModel

from agentstack_sdk.a2a.extensions.common.form import FormRender, TextField
from agentstack_sdk.a2a.extensions.services.form import (
FormServiceExtensionServer,
FormServiceExtensionSpec,
)
from agentstack_sdk.server import Server

server = Server()


class FormData(BaseModel):
mood: str | None


@server.agent()
async def form_agent(
_message: Message,
form: Annotated[
FormServiceExtensionServer,
FormServiceExtensionSpec.demand(
initial_form=FormRender(
title="How are you?",
fields=[TextField(id="mood", label="Mood", type="text", col_span=1)],
)
),
],
):
"""Initial form agent"""
initial_form = form.parse_initial_form(model=FormData)
if initial_form is None:
yield "No form data received."
else:
yield f"Your mood is {initial_form.mood}"


if __name__ == "__main__":
server.run()
119 changes: 57 additions & 62 deletions apps/agentstack-sdk-py/examples/request_form_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,88 +3,83 @@
from typing import Annotated

from a2a.types import Message
from pydantic import BaseModel

from agentstack_sdk.a2a.extensions.ui.form import (
from agentstack_sdk.a2a.extensions.common.form import (
CheckboxField,
DateField,
FileField,
FormExtensionServer,
FormExtensionSpec,
FileInfo,
FormRender,
MultiSelectField,
OptionItem,
SingleSelectField,
TextField,
)
from agentstack_sdk.a2a.extensions.ui.request_form import RequestFormExtensionServer, RequestFormExtensionSpec
from agentstack_sdk.server import Server

server = Server()


class KitchenSink(BaseModel):
text_field: str | None
date_field: str | None
file_field: list[FileInfo] | None
singleselect_field: str | None
multiselect_field: list[str] | None
checkbox_field: bool | None


@server.agent()
async def request_form_agent(
_message: Message,
form: Annotated[
FormExtensionServer,
FormExtensionSpec(
params=FormRender(
id="initial_form",
title="How are you?",
fields=[TextField(id="mood", label="Mood", type="text", col_span=1)],
)
),
request_form: Annotated[
RequestFormExtensionServer,
RequestFormExtensionSpec(),
],
):
"""Request form data"""
try:
form_data = await form.request_form(
form=FormRender(
id="all_fields_form",
title="Kitchen Sink Form",
columns=2,
fields=[
TextField(id="text_field", label="Text Field", col_span=1),
DateField(id="date_field", label="Date Field", col_span=1),
FileField(id="file_field", label="File Field", accept=["*/*"], col_span=2),
SingleSelectField(
id="singleselect_field",
label="Single-Select Field",
options=[
OptionItem(id="option1", label="Option 1"),
OptionItem(id="option2", label="Option 2"),
],
col_span=2,
),
MultiSelectField(
id="multiselect_field",
label="Multi-Select Field",
options=[
OptionItem(id="option1", label="Option 1"),
OptionItem(id="option2", label="Option 2"),
],
col_span=2,
),
CheckboxField(
id="checkbox_field",
label="Checkbox Field",
content="I agree to the terms and conditions.",
col_span=2,
),
],
)
)

if form_data is None:
yield "No form data received."
return

response = "Form data received:\n"
for field_id, field_value in form_data.values.items():
response += f"- {field_id}: {field_value.value}\n"
yield response

except ValueError:
yield "Sorry, but I can't continue without receiving the form data."
"""Request form agent"""
user_info = await request_form.request_form(
form=FormRender(
title="Kitchen Sink Form",
columns=2,
fields=[
TextField(id="text_field", label="Text Field", col_span=1),
DateField(id="date_field", label="Date Field", col_span=1),
FileField(id="file_field", label="File Field", accept=["*/*"], col_span=2),
SingleSelectField(
id="singleselect_field",
label="Single-Select Field",
options=[
OptionItem(id="option1", label="Option 1"),
OptionItem(id="option2", label="Option 2"),
],
col_span=2,
),
MultiSelectField(
id="multiselect_field",
label="Multi-Select Field",
options=[
OptionItem(id="option1", label="Option 1"),
OptionItem(id="option2", label="Option 2"),
],
col_span=2,
),
CheckboxField(
id="checkbox_field",
label="Checkbox Field",
content="I agree to the terms and conditions.",
col_span=2,
),
],
),
model=KitchenSink,
)
if user_info is None:
yield "No user info received."
else:
yield user_info.model_dump_json()


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
# SPDX-License-Identifier: Apache-2.0

from .form import *
Loading
Loading