diff --git a/README.md b/README.md index 8d38bedc5..cfd018325 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Reference implementations demonstrating core Agent Stack capabilities. - [Form Agent](https://github.com/i-am-bee/agentstack/tree/main/agents/form) - Single-turn form interaction using Form Extension with multiple field types, customizable layouts, file uploads, validation, and structured output. - [RAG Agent](https://github.com/i-am-bee/agentstack/tree/main/agents/rag) - Retrieval-Augmented Generation agent supporting 12+ file formats, dynamic vector stores, semantic search (VectorSearchTool), document summaries (FileReaderTool), intelligent tool selection, and citation tracking with document URLs. - [OAuth Agent](https://github.com/i-am-bee/agentstack/blob/main/apps/agentstack-sdk-py/examples/oauth.py) - OAuth Extension demo with MCP integration, browser-based authorization, secure token management, and Stripe MCP server access. -- [Dynamic Form Request Agent](https://github.com/i-am-bee/agentstack/blob/main/apps/agentstack-sdk-py/examples/request_form_agent.py) - Multi-step form workflow showing both static and dynamic form generation, where the agent conditionally requests additional input mid-conversation. +- [Dynamic Form Request Agent](https://github.com/i-am-bee/agentstack/blob/main/apps/agentstack-sdk-py/examples/form_request_agent.py) - Multi-step form workflow showing both static and dynamic form generation, where the agent conditionally requests additional input mid-conversation. - [Flight Search and Visualization Agent](https://github.com/jezekra1/agentstack-workshop) - Agent that queries the Kiwi.com MCP API for flight results, requests missing parameters through the Form Extension, and optionally generates PNG or HTML route visualizations using geospatial helpers. It uses RequirementAgent to orchestrate tool calls (data validation and visualization) and streams a final answer with any generated files and citations. ### Community Agents diff --git a/agents/form/src/form/agent.py b/agents/form/src/form/agent.py index f0615aece..6d4e37961 100644 --- a/agents/form/src/form/agent.py +++ b/agents/form/src/form/agent.py @@ -3,7 +3,7 @@ import os from typing import Annotated - +from pydantic import BaseModel import a2a.server.agent_execution import a2a.server.apps @@ -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( @@ -65,15 +68,21 @@ ) 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", @@ -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(): diff --git a/agents/form/uv.lock b/agents/form/uv.lock index ecf8fec78..3ef6c1aa5 100644 --- a/agents/form/uv.lock +++ b/agents/form/uv.lock @@ -63,7 +63,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "beeai-framework", extras = ["duckduckgo", "wikipedia"], specifier = ">=0.1.58" }, + { name = "beeai-framework", extras = ["duckduckgo", "wikipedia"], specifier = ">=0.1.67" }, { name = "pyright", specifier = ">=1.1.403" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, diff --git a/apps/agentstack-cli/src/agentstack_cli/commands/agent.py b/apps/agentstack-cli/src/agentstack_cli/commands/agent.py index e5f58934f..c19debf6e 100644 --- a/apps/agentstack-cli/src/agentstack_cli/commands/agent.py +++ b/apps/agentstack-cli/src/agentstack_cli/commands/agent.py @@ -36,6 +36,8 @@ EmbeddingFulfillment, EmbeddingServiceExtensionClient, EmbeddingServiceExtensionSpec, + FormRequestExtensionSpec, + FormServiceExtensionSpec, LLMFulfillment, LLMServiceExtensionClient, LLMServiceExtensionSpec, @@ -44,12 +46,11 @@ 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, @@ -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( @@ -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 {} ) @@ -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(FormRequestExtensionSpec.URI) if message and message.metadata else None ): stream = client.send_message( Message( @@ -480,7 +485,7 @@ async def _run_agent( task_id=task_id, context_id=context_token.context_id, metadata={ - FormExtensionSpec.URI: ( + FormRequestExtensionSpec.URI: ( await _ask_form_questions(FormRender.model_validate(form_metadata)) ).model_dump(mode="json") }, @@ -803,9 +808,9 @@ async def run_agent( initial_form_render = next( ( - 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, ) diff --git a/apps/agentstack-sdk-py/examples/form_agent.py b/apps/agentstack-sdk-py/examples/form_agent.py new file mode 100644 index 000000000..9e23b6861 --- /dev/null +++ b/apps/agentstack-sdk-py/examples/form_agent.py @@ -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() diff --git a/apps/agentstack-sdk-py/examples/form_request_agent.py b/apps/agentstack-sdk-py/examples/form_request_agent.py new file mode 100644 index 000000000..1823d46a3 --- /dev/null +++ b/apps/agentstack-sdk-py/examples/form_request_agent.py @@ -0,0 +1,86 @@ +# 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 ( + CheckboxField, + DateField, + FileField, + FileInfo, + FormRender, + MultiSelectField, + OptionItem, + SingleSelectField, + TextField, +) +from agentstack_sdk.a2a.extensions.ui.form_request import FormRequestExtensionServer, FormRequestExtensionSpec +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 form_request_agent( + _message: Message, + form_request: Annotated[ + FormRequestExtensionServer, + FormRequestExtensionSpec(), + ], +): + """Request form agent""" + user_info = await form_request.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__": + server.run() diff --git a/apps/agentstack-sdk-py/examples/request_form_agent.py b/apps/agentstack-sdk-py/examples/request_form_agent.py deleted file mode 100644 index 14723c1ab..000000000 --- a/apps/agentstack-sdk-py/examples/request_form_agent.py +++ /dev/null @@ -1,91 +0,0 @@ -# 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 agentstack_sdk.a2a.extensions.ui.form import ( - CheckboxField, - DateField, - FileField, - FormExtensionServer, - FormExtensionSpec, - FormRender, - MultiSelectField, - OptionItem, - SingleSelectField, - TextField, -) -from agentstack_sdk.server import Server - -server = Server() - - -@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 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." - - -if __name__ == "__main__": - server.run() diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/common/__init__.py b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/common/__init__.py new file mode 100644 index 000000000..f6da384b6 --- /dev/null +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/common/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 © BeeAI a Series of LF Projects, LLC +# SPDX-License-Identifier: Apache-2.0 + +from .form import * diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/form.py b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/common/form.py similarity index 68% rename from apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/form.py rename to apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/common/form.py index a59b16936..3f26f77c1 100644 --- a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/form.py +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/common/form.py @@ -1,19 +1,9 @@ # Copyright 2025 © BeeAI a Series of LF Projects, LLC # SPDX-License-Identifier: Apache-2.0 +from typing import Literal -from __future__ import annotations - -from typing import TYPE_CHECKING, Literal, TypeVar, cast - -from a2a.types import Message as A2AMessage -from pydantic import BaseModel, Field, TypeAdapter, model_validator - -from agentstack_sdk.a2a.extensions.base import BaseExtensionClient, BaseExtensionServer, BaseExtensionSpec -from agentstack_sdk.a2a.types import AgentMessage, InputRequired - -if TYPE_CHECKING: - from agentstack_sdk.server.context import RunContext +from pydantic import BaseModel, Field, model_validator class BaseField(BaseModel): @@ -91,7 +81,6 @@ class CheckboxField(BaseField): class FormRender(BaseModel): - id: str title: str | None = None description: str | None = None columns: int | None = Field(default=None, ge=1, le=4) @@ -146,7 +135,6 @@ class CheckboxFieldValue(BaseModel): class FormResponse(BaseModel): - id: str values: dict[str, FormFieldValue] def __iter__(self): @@ -159,35 +147,3 @@ def __iter__(self): ) case _: yield key, value.value - - -class FormExtensionSpec(BaseExtensionSpec[FormRender | None]): - URI: str = "https://a2a-extensions.agentstack.beeai.dev/ui/form/v1" - - -T = TypeVar("T") - - -class FormExtensionServer(BaseExtensionServer[FormExtensionSpec, FormResponse]): - context: RunContext - - def handle_incoming_message(self, message: A2AMessage, context: RunContext): - super().handle_incoming_message(message, context) - self.context = context - - async def request_form(self, *, form: FormRender, model: type[T] = FormResponse) -> T | None: - message = await self.context.yield_async( - InputRequired(message=AgentMessage(text=form.title, metadata={self.spec.URI: form})) - ) - return self.parse_form_response(message=message, model=model) if message else None - - def parse_form_response(self, *, message: A2AMessage, model: type[T] = FormResponse) -> T | None: - form_response = self.parse_client_metadata(message) - if form_response is None: - return None - if model is FormResponse: - return cast(T, form_response) - return TypeAdapter(model).validate_python(dict(form_response)) - - -class FormExtensionClient(BaseExtensionClient[FormExtensionSpec, FormRender]): ... diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/services/__init__.py b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/services/__init__.py index 2ad3218d3..beaf0fe02 100644 --- a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/services/__init__.py +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/services/__init__.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 from .embedding import * +from .form import * from .llm import * from .mcp import * from .platform import * diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/services/form.py b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/services/form.py new file mode 100644 index 000000000..f00d9b5cb --- /dev/null +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/services/form.py @@ -0,0 +1,53 @@ +# Copyright 2025 © BeeAI a Series of LF Projects, LLC +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +from typing import Self, TypedDict, TypeVar, cast + +from pydantic import BaseModel, TypeAdapter + +from agentstack_sdk.a2a.extensions.base import BaseExtensionClient, BaseExtensionServer, BaseExtensionSpec +from agentstack_sdk.a2a.extensions.common.form import FormRender, FormResponse + + +class FormDemands(TypedDict): + initial_form: FormRender | None + # TODO: We can put settings here too + + +class FormServiceExtensionMetadata(BaseModel): + form_fulfillments: dict[str, FormResponse] = {} + + +class FormServiceExtensionParams(BaseModel): + form_demands: FormDemands + + +class FormServiceExtensionSpec(BaseExtensionSpec[FormServiceExtensionParams]): + URI: str = "https://a2a-extensions.agentstack.beeai.dev/services/form/v1" + + @classmethod + def demand(cls, initial_form: FormRender | None) -> Self: + return cls(params=FormServiceExtensionParams(form_demands={"initial_form": initial_form})) + + +T = TypeVar("T") + + +class FormServiceExtensionServer(BaseExtensionServer[FormServiceExtensionSpec, FormServiceExtensionMetadata]): + def parse_initial_form(self, *, model: type[T] = FormResponse) -> T | None: + if self.data is None: + return None + + initial_form = self.data.form_fulfillments.get("initial_form") + + if initial_form is None: + return None + if model is FormResponse: + return cast(T, initial_form) + return TypeAdapter(model).validate_python(dict(initial_form)) + + +class FormServiceExtensionClient(BaseExtensionClient[FormServiceExtensionSpec, FormRender]): ... diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/__init__.py b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/__init__.py index ca54a6eee..0f8f93d01 100644 --- a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/__init__.py +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/__init__.py @@ -3,6 +3,6 @@ from .agent_detail import * from .citation import * -from .form import * +from .form_request import * from .settings import * from .trajectory import * diff --git a/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/form_request.py b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/form_request.py new file mode 100644 index 000000000..acc0eda1e --- /dev/null +++ b/apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/form_request.py @@ -0,0 +1,49 @@ +# Copyright 2025 © BeeAI a Series of LF Projects, LLC +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar, cast + +from a2a.types import Message as A2AMessage +from pydantic import TypeAdapter + +from agentstack_sdk.a2a.extensions.base import ( + BaseExtensionClient, + BaseExtensionServer, + NoParamsBaseExtensionSpec, +) +from agentstack_sdk.a2a.extensions.common.form import FormRender, FormResponse +from agentstack_sdk.a2a.types import AgentMessage, InputRequired + +if TYPE_CHECKING: + from agentstack_sdk.server.context import RunContext + +T = TypeVar("T") + + +class FormRequestExtensionSpec(NoParamsBaseExtensionSpec): + URI: str = "https://a2a-extensions.agentstack.beeai.dev/ui/form_request/v1" + + +class FormRequestExtensionServer(BaseExtensionServer[FormRequestExtensionSpec, FormResponse]): + def handle_incoming_message(self, message: A2AMessage, context: RunContext): + super().handle_incoming_message(message, context) + self.context = context + + async def request_form(self, *, form: FormRender, model: type[T] = FormResponse) -> T | None: + message = await self.context.yield_async( + InputRequired(message=AgentMessage(text=form.title, metadata={self.spec.URI: form})) + ) + return self.parse_form_response(message=message, model=model) if message else None + + def parse_form_response(self, *, message: A2AMessage, model: type[T] = FormResponse) -> T | None: + form_response = self.parse_client_metadata(message) + if form_response is None: + return None + if model is FormResponse: + return cast(T, form_response) + return TypeAdapter(model).validate_python(dict(form_response)) + + +class FormRequestExtensionClient(BaseExtensionClient[FormRequestExtensionSpec, FormRender]): ... diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/form.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form.ts similarity index 69% rename from apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/form.ts rename to apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form.ts index 69445381f..039447792 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/form.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form.ts @@ -3,11 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; - -import type { A2AServiceExtension, A2AUiExtension } from '../types'; - -const URI = 'https://a2a-extensions.agentstack.beeai.dev/ui/form/v1'; +import z from 'zod'; const baseField = z.object({ id: z.string().nonempty(), @@ -23,7 +19,7 @@ const textField = baseField.extend({ auto_resize: z.boolean().default(true).nullish(), }); -const textFieldValue = z.object({ +export const textFieldValue = z.object({ type: textField.shape.type, value: z.string().nullish(), }); @@ -34,7 +30,7 @@ const dateField = baseField.extend({ default_value: z.string().nullish(), }); -const dateFieldValue = z.object({ +export const dateFieldValue = z.object({ type: dateField.shape.type, value: z.string().nullish(), }); @@ -44,7 +40,7 @@ const fileField = baseField.extend({ accept: z.array(z.string()), }); -const fileFieldValue = z.object({ +export const fileFieldValue = z.object({ type: fileField.shape.type, value: z .array( @@ -57,7 +53,7 @@ const fileFieldValue = z.object({ .nullish(), }); -const singleSelectField = baseField.extend({ +export const singleSelectField = baseField.extend({ type: z.literal('singleselect'), options: z .array( @@ -70,12 +66,12 @@ const singleSelectField = baseField.extend({ default_value: z.string().nullish(), }); -const singleSelectFieldValue = z.object({ +export const singleSelectFieldValue = z.object({ type: singleSelectField.shape.type, value: z.string().nullish(), }); -const multiSelectField = baseField.extend({ +export const multiSelectField = baseField.extend({ type: z.literal('multiselect'), options: z .array( @@ -88,18 +84,18 @@ const multiSelectField = baseField.extend({ default_value: z.array(z.string()).nullish(), }); -const multiSelectFieldValue = z.object({ +export const multiSelectFieldValue = z.object({ type: multiSelectField.shape.type, value: z.array(z.string()).nullish(), }); -const checkboxField = baseField.extend({ +export const checkboxField = baseField.extend({ type: z.literal('checkbox'), content: z.string(), default_value: z.boolean(), }); -const checkboxFieldValue = z.object({ +export const checkboxFieldValue = z.object({ type: checkboxField.shape.type, value: z.boolean().nullish(), }); @@ -113,8 +109,7 @@ const fieldSchema = z.discriminatedUnion('type', [ checkboxField, ]); -const renderSchema = z.object({ - id: z.string().nonempty(), +export const formRenderSchema = z.object({ title: z.string().nullish(), description: z.string().nullish(), columns: z.int().min(1).max(4).nullish(), @@ -122,8 +117,7 @@ const renderSchema = z.object({ fields: z.array(fieldSchema).nonempty(), }); -const responseSchema = z.object({ - id: z.string().nonempty(), +export const formResponseSchema = z.object({ values: z.record( z.string(), z.discriminatedUnion('type', [ @@ -137,6 +131,8 @@ const responseSchema = z.object({ ), }); +export type FormRender = z.infer; + export type TextField = z.infer; export type DateField = z.infer; export type FileField = z.infer; @@ -145,17 +141,4 @@ export type MultiSelectField = z.infer; export type CheckboxField = z.infer; export type FormField = z.infer; - -export type FormDemands = z.infer; -export type FormFulfillments = z.infer; -export type FormResponseValue = FormFulfillments['values'][string]; - -export const formMessageExtension: A2AUiExtension = { - getMessageMetadataSchema: () => z.object({ [URI]: renderSchema }).partial(), - getUri: () => URI, -}; -export const formExtension: A2AServiceExtension, FormFulfillments> = { - getDemandsSchema: () => renderSchema, - getFulfillmentSchema: () => responseSchema, - getUri: () => URI, -}; +export type FormResponseValue = z.infer['values'][string]; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-agent-card.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-agent-card.ts index 15d77a89e..fd9581b01 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-agent-card.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-agent-card.ts @@ -8,6 +8,8 @@ import type { AgentCapabilities } from '@a2a-js/sdk'; import type { ContextToken } from '../../context/types'; import type { EmbeddingDemands, EmbeddingFulfillments } from './services/embedding'; import { embeddingExtension } from './services/embedding'; +import type { FormDemands, FormFulfillments } from './services/form'; +import { formExtension } from './services/form'; import type { LLMDemands, LLMFulfillments } from './services/llm'; import { llmExtension } from './services/llm'; import type { MCPDemands, MCPFulfillments } from './services/mcp'; @@ -17,8 +19,6 @@ import { oauthProviderExtension } from './services/oauth-provider'; import { platformApiExtension } from './services/platform'; import type { SecretDemands, SecretFulfillments } from './services/secrets'; import { secretsExtension } from './services/secrets'; -import type { FormFulfillments } from './ui/form'; -import { formExtension } from './ui/form'; import { oauthRequestExtension } from './ui/oauth'; import type { SettingsDemands, SettingsFulfillments } from './ui/settings'; import { settingsExtension } from './ui/settings'; @@ -31,7 +31,7 @@ export interface Fulfillments { oauth: (demand: OAuthDemands) => Promise; settings: (demand: SettingsDemands) => Promise; secrets: (demand: SecretDemands) => Promise; - form: () => Promise; + form: (demand: FormDemands) => Promise; oauthRedirectUri: () => string | null; getContextToken: () => ContextToken; } @@ -64,7 +64,7 @@ export const handleAgentCard = (agentCard: { capabilities: AgentCapabilities }) const formDemands = formExtensionExtractor(extensions); const resolveMetadata = async (fulfillments: Fulfillments) => { - let fulfilledMetadata = {}; + let fulfilledMetadata: Record = {}; fulfilledMetadata = platformApiExtension(fulfilledMetadata, fulfillments.getContextToken()); @@ -92,9 +92,8 @@ export const handleAgentCard = (agentCard: { capabilities: AgentCapabilities }) fulfilledMetadata = fulfillSecretDemand(fulfilledMetadata, await fulfillments.secrets(secretDemands)); } - const formFulfillment = await fulfillments.form(); - if (formFulfillment) { - fulfilledMetadata = fulfillFormDemand(fulfilledMetadata, formFulfillment); + if (formDemands) { + fulfilledMetadata = fulfillFormDemand(fulfilledMetadata, await fulfillments.form(formDemands)); } const oauthRedirectUri = fulfillments.oauthRedirectUri(); diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-input-required.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-input-required.ts new file mode 100644 index 000000000..0401844d2 --- /dev/null +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-input-required.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2025 © BeeAI a Series of LF Projects, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { FormResponseValue } from './common/form'; +import { FormRequestExtension } from './ui/form-request'; + +export type InputRequiredResponses = Partial<{ + form: Record; +}>; + +export const handleInputRequired = () => { + const resolveMetadata = async (responses: InputRequiredResponses) => { + const metadata: Record = {}; + + if (responses.form) { + metadata[FormRequestExtension.getUri()] = { + values: responses.form, + }; + } + + return metadata; + }; + + return { + resolveMetadata, + }; +}; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-task-status-update.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-task-status-update.ts index 21d031dfc..3b4b438f6 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-task-status-update.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/handle-task-status-update.ts @@ -5,16 +5,16 @@ import type { TaskStatusUpdateEvent } from '@a2a-js/sdk'; +import type { FormRender } from './common/form'; import type { SecretDemands } from './services/secrets'; import { secretsMessageExtension } from './services/secrets'; -import type { FormDemands } from './ui/form'; -import { formMessageExtension } from './ui/form'; +import { FormRequestExtension } from './ui/form-request'; import { oauthRequestExtension } from './ui/oauth'; import { extractUiExtensionData } from './utils'; const secretsMessageExtensionExtractor = extractUiExtensionData(secretsMessageExtension); -const formMessageExtensionExtractor = extractUiExtensionData(formMessageExtension); const oauthRequestExtensionExtractor = extractUiExtensionData(oauthRequestExtension); +const FormRequestExtensionExtractor = extractUiExtensionData(FormRequestExtension); export enum TaskStatusUpdateType { SecretRequired = 'secret-required', @@ -29,7 +29,7 @@ export interface SecretRequiredResult { export interface FormRequiredResult { type: TaskStatusUpdateType.FormRequired; - form: FormDemands; + form: FormRender; } export interface OAuthRequiredResult { @@ -60,7 +60,8 @@ export const handleTaskStatusUpdate = (event: TaskStatusUpdateEvent): TaskStatus }); } } else if (event.status.state === 'input-required') { - const formRequired = formMessageExtensionExtractor(event.status.message?.metadata); + const formRequired = FormRequestExtensionExtractor(event.status.message?.metadata); + if (formRequired) { results.push({ type: TaskStatusUpdateType.FormRequired, diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/embedding.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/embedding.ts index d7c20413d..5f1387995 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/embedding.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/embedding.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AServiceExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form.ts new file mode 100644 index 000000000..04db23cd1 --- /dev/null +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2025 © BeeAI a Series of LF Projects, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import z from 'zod'; + +import { formRenderSchema, formResponseSchema } from '../common/form'; +import type { A2AServiceExtension } from '../types'; + +const URI = 'https://a2a-extensions.agentstack.beeai.dev/services/form/v1'; + +const formDemandSchema = z.object({ + form_demands: z + .object({ + initial_form: formRenderSchema, + }) + .partial(), +}); +export type FormDemands = z.infer; + +const formFulfillmentSchema = z.object({ + form_fulfillments: z.record(z.string(), formResponseSchema), +}); +export type FormFulfillments = z.infer; + +export const formExtension: A2AServiceExtension = { + getDemandsSchema: () => formDemandSchema, + getFulfillmentSchema: () => formFulfillmentSchema, + getUri: () => URI, +}; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/llm.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/llm.ts index 9a341fa65..ce6c383b7 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/llm.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/llm.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AServiceExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/mcp.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/mcp.ts index a9cc57fb5..a8d885b2c 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/mcp.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/mcp.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AServiceExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/oauth-provider.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/oauth-provider.ts index 06f1291ef..c8201f1f6 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/oauth-provider.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/oauth-provider.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AServiceExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/secrets.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/secrets.ts index 26a2c9b67..6e83cf17c 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/secrets.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/secrets.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AServiceExtension, A2AUiExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/agent-detail.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/agent-detail.ts index c353b8829..da0b9b04a 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/agent-detail.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/agent-detail.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import { interactionModeSchema } from '../../../../types'; import type { A2AUiExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/citation.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/citation.ts index 8f1156ef3..e5d711d64 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/citation.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/citation.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AUiExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/form-request.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/form-request.ts new file mode 100644 index 000000000..f3eeb192e --- /dev/null +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/form-request.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2025 © BeeAI a Series of LF Projects, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import z from 'zod'; + +import { formRenderSchema } from '../common/form'; +import type { A2AUiExtension } from '../types'; + +const URI = 'https://a2a-extensions.agentstack.beeai.dev/ui/form_request/v1'; + +export type FormRequest = z.infer; + +export const FormRequestExtension: A2AUiExtension = { + getMessageMetadataSchema: () => z.object({ [URI]: formRenderSchema }).partial(), + getUri: () => URI, +}; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/oauth.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/oauth.ts index ac492fd5e..4ed94da94 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/oauth.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/oauth.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AUiExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings.ts index 19f55e570..71b6d04c5 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AServiceExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/trajectory.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/trajectory.ts index 7a43867c7..2881e9303 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/trajectory.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/trajectory.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; import type { A2AUiExtension } from '../types'; diff --git a/apps/agentstack-sdk-ts/src/index.ts b/apps/agentstack-sdk-ts/src/index.ts index 4f7677818..5b56021e5 100644 --- a/apps/agentstack-sdk-ts/src/index.ts +++ b/apps/agentstack-sdk-ts/src/index.ts @@ -3,13 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './client/a2a/extensions/common/form'; export { type Fulfillments, handleAgentCard } from './client/a2a/extensions/handle-agent-card'; +export { handleInputRequired, type InputRequiredResponses } from './client/a2a/extensions/handle-input-required'; export { handleTaskStatusUpdate, type TaskStatusUpdateResult, TaskStatusUpdateType, } from './client/a2a/extensions/handle-task-status-update'; export * from './client/a2a/extensions/services/embedding'; +export * from './client/a2a/extensions/services/form'; export * from './client/a2a/extensions/services/llm'; export * from './client/a2a/extensions/services/mcp'; export * from './client/a2a/extensions/services/oauth-provider'; @@ -18,7 +21,7 @@ export * from './client/a2a/extensions/services/secrets'; export * from './client/a2a/extensions/types'; export * from './client/a2a/extensions/ui/agent-detail'; export * from './client/a2a/extensions/ui/citation'; -export * from './client/a2a/extensions/ui/form'; +export * from './client/a2a/extensions/ui/form-request'; export * from './client/a2a/extensions/ui/oauth'; export * from './client/a2a/extensions/ui/settings'; export * from './client/a2a/extensions/ui/trajectory'; diff --git a/apps/agentstack-ui/src/api/a2a/client.ts b/apps/agentstack-ui/src/api/a2a/client.ts index 8e77333aa..b651880dd 100644 --- a/apps/agentstack-ui/src/api/a2a/client.ts +++ b/apps/agentstack-ui/src/api/a2a/client.ts @@ -5,7 +5,7 @@ import type { TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '@a2a-js/sdk'; import { A2AClient } from '@a2a-js/sdk/client'; -import { handleAgentCard, handleTaskStatusUpdate } from 'agentstack-sdk'; +import { handleAgentCard, handleInputRequired, handleTaskStatusUpdate } from 'agentstack-sdk'; import { defaultIfEmpty, filter, lastValueFrom, Subject } from 'rxjs'; import { match } from 'ts-pattern'; @@ -65,18 +65,22 @@ export const buildA2AClient = async ({ const client = await A2AClient.fromCardUrl(agentCardUrl, { fetchImpl: clientFetch }); const card = await client.getAgentCard(); - const { resolveMetadata, demands } = handleAgentCard(card); + const { resolveMetadata: resolveAgentCardMetadata, demands } = handleAgentCard(card); + const { resolveMetadata: resolveInputRequiredMetadata } = handleInputRequired(); - const chat = ({ message, contextId, fulfillments, taskId: initialTaskId }: ChatParams) => { + const chat = ({ message, contextId, fulfillments, responses, taskId: initialTaskId }: ChatParams) => { const messageSubject = new Subject>(); let taskId: undefined | TaskId = initialTaskId; const iterateOverStream = async () => { - const extensionsMetadata = await resolveMetadata(fulfillments); + const agentCardMetadata = await resolveAgentCardMetadata(fulfillments); + const inputRequiredMetadata = await resolveInputRequiredMetadata(responses); + + const metadata = { ...agentCardMetadata, ...inputRequiredMetadata }; const stream = client.sendMessageStream({ - message: createUserMessage({ message, contextId, metadata: extensionsMetadata, taskId }), + message: createUserMessage({ message, contextId, metadata, taskId }), }); const taskResult = lastValueFrom( diff --git a/apps/agentstack-ui/src/api/a2a/part-processors.ts b/apps/agentstack-ui/src/api/a2a/part-processors.ts index 0c2d82ae4..1ad469a1e 100644 --- a/apps/agentstack-ui/src/api/a2a/part-processors.ts +++ b/apps/agentstack-ui/src/api/a2a/part-processors.ts @@ -12,12 +12,10 @@ import { UIMessagePartKind } from '#modules/messages/types.ts'; import { isNotNull } from '#utils/helpers.ts'; import { - createFormPart, createSourcePart, createTextPart, createTrajectoryPart, extractCitation, - extractForm, extractTrajectory, getFileUrl, } from './utils'; @@ -25,7 +23,6 @@ import { export function processMessageMetadata(message: Message): UIMessagePart[] { const trajectory = extractTrajectory(message.metadata); const citations = extractCitation(message.metadata)?.citations; - const form = extractForm(message.metadata); const parts: UIMessagePart[] = []; @@ -37,9 +34,6 @@ export function processMessageMetadata(message: Message): UIMessagePart[] { parts.push(...sourceParts); } - if (form) { - parts.push(createFormPart(form)); - } return parts; } diff --git a/apps/agentstack-ui/src/api/a2a/types.ts b/apps/agentstack-ui/src/api/a2a/types.ts index 58f18aaa2..8297d8203 100644 --- a/apps/agentstack-ui/src/api/a2a/types.ts +++ b/apps/agentstack-ui/src/api/a2a/types.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Fulfillments, TaskStatusUpdateResult } from 'agentstack-sdk'; +import type { Fulfillments, InputRequiredResponses, TaskStatusUpdateResult } from 'agentstack-sdk'; import type { UIMessagePart, UIUserMessage } from '#modules/messages/types.ts'; import type { ContextId, TaskId } from '#modules/tasks/api/types.ts'; @@ -30,6 +30,7 @@ export interface ChatParams { message: UIUserMessage; contextId: ContextId; fulfillments: Fulfillments; + responses: InputRequiredResponses; taskId?: TaskId; } diff --git a/apps/agentstack-ui/src/api/a2a/utils.ts b/apps/agentstack-ui/src/api/a2a/utils.ts index bae237d0e..f23fd2f4c 100644 --- a/apps/agentstack-ui/src/api/a2a/utils.ts +++ b/apps/agentstack-ui/src/api/a2a/utils.ts @@ -8,8 +8,6 @@ import { type Citation, citationExtension, extractUiExtensionData, - type FormDemands, - formMessageExtension, trajectoryExtension, type TrajectoryMetadata, } from 'agentstack-sdk'; @@ -18,7 +16,6 @@ import { v4 as uuid } from 'uuid'; import { getFileContentUrl } from '#modules/files/utils.ts'; import type { - UIFormPart, UIMessagePart, UISourcePart, UITextPart, @@ -33,7 +30,6 @@ import { PLATFORM_FILE_CONTENT_URL_BASE } from './constants'; export const extractCitation = extractUiExtensionData(citationExtension); export const extractTrajectory = extractUiExtensionData(trajectoryExtension); -export const extractForm = extractUiExtensionData(formMessageExtension); export function extractTextFromMessage(message: Message | undefined) { const text = message?.parts @@ -164,15 +160,6 @@ export function createTextPart(text: string): UITextPart { return textPart; } -export function createFormPart(form: FormDemands): UIFormPart { - const formPart: UIFormPart = { - kind: UIMessagePartKind.Form, - ...form, - }; - - return formPart; -} - export function getFilePlatformUrl(id: string) { return `${PLATFORM_FILE_CONTENT_URL_BASE}${id}`; } diff --git a/apps/agentstack-ui/src/modules/form/components/FormFields.tsx b/apps/agentstack-ui/src/modules/form/components/FormFields.tsx index bf2515911..555d6ba93 100644 --- a/apps/agentstack-ui/src/modules/form/components/FormFields.tsx +++ b/apps/agentstack-ui/src/modules/form/components/FormFields.tsx @@ -3,16 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FormDemands, FormFulfillments } from 'agentstack-sdk'; +import type { FormRender } from 'agentstack-sdk'; import type { CSSProperties } from 'react'; +import type { RunFormValues } from '../types'; import { FormField } from './FormField'; import classes from './FormFields.module.scss'; interface Props { - fields: FormDemands['fields']; - columns: FormDemands['columns']; - values?: FormFulfillments['values']; + fields: FormRender['fields']; + columns: FormRender['columns']; + values?: RunFormValues; } export function FormFields({ fields, columns, values }: Props) { diff --git a/apps/agentstack-ui/src/modules/form/components/FormRenderer.tsx b/apps/agentstack-ui/src/modules/form/components/FormRenderer.tsx index d575dd045..2ca0fb4da 100644 --- a/apps/agentstack-ui/src/modules/form/components/FormRenderer.tsx +++ b/apps/agentstack-ui/src/modules/form/components/FormRenderer.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FormDemands } from 'agentstack-sdk'; +import type { FormRender } from 'agentstack-sdk'; import { FormProvider, useForm } from 'react-hook-form'; import { AgentRunHeader } from '#modules/agents/components/detail/AgentRunHeader.tsx'; @@ -17,7 +17,7 @@ import { FormFields } from './FormFields'; import classes from './FormRenderer.module.scss'; interface Props { - definition: FormDemands; + definition: FormRender; defaultHeading?: string | null; showHeading?: boolean; isDisabled?: boolean; @@ -33,7 +33,7 @@ export function FormRenderer({ isDisabled, onSubmit, }: Props) { - const { id, title: heading = defaultHeading, description, columns, submit_label, fields } = definition; + const { title: heading = defaultHeading, description, columns, submit_label, fields } = definition; const defaultValues = getDefaultValues(fields); @@ -44,7 +44,7 @@ export function FormRenderer({ return ( -
+
{showHeader && ( diff --git a/apps/agentstack-ui/src/modules/form/components/MessageForm.tsx b/apps/agentstack-ui/src/modules/form/components/MessageForm.tsx index 1687c010c..4710f728a 100644 --- a/apps/agentstack-ui/src/modules/form/components/MessageForm.tsx +++ b/apps/agentstack-ui/src/modules/form/components/MessageForm.tsx @@ -27,13 +27,13 @@ export function MessageForm({ message }: Props) { return ( { const form = { - request: formPart, - response: { id: formPart.id, values }, + request: formPart.render, + response: values, }; if (!message.taskId) { diff --git a/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx b/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx index de38970fa..23dfbe1f1 100644 --- a/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx +++ b/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx @@ -31,7 +31,7 @@ interface Props { export function MessageFormResponse({ form }: Props) { const { showSubmission, setShowSubmission } = useMessageForm(); - const formReturn = useForm({ values: form.response?.values }); + const formReturn = useForm({ values: form.response }); const data: FieldWithValue[] | null = useMemo(() => { if (!form.response) { @@ -39,7 +39,7 @@ export function MessageFormResponse({ form }: Props) { } return form.request.fields .map((field) => { - const value = form.response?.values[field.id]; + const value = form.response[field.id]; return value && value.type === field.type ? ({ ...field, value: value.value } as FieldWithValue) : null; }) .filter(isNotNull); @@ -69,7 +69,7 @@ export function MessageFormResponse({ form }: Props) { {showSubmission && (
- +
)} diff --git a/apps/agentstack-ui/src/modules/messages/types.ts b/apps/agentstack-ui/src/modules/messages/types.ts index c952c655f..34d50e3a8 100644 --- a/apps/agentstack-ui/src/modules/messages/types.ts +++ b/apps/agentstack-ui/src/modules/messages/types.ts @@ -4,8 +4,9 @@ */ import type { Task } from '@a2a-js/sdk'; -import type { FormDemands, FormFulfillments, SecretDemands } from 'agentstack-sdk'; +import type { FormRender, SecretDemands } from 'agentstack-sdk'; +import type { RunFormValues } from '#modules/form/types.ts'; import type { TaskId } from '#modules/tasks/api/types.ts'; import type { Role } from './api/types'; @@ -82,8 +83,9 @@ export type UITrajectoryPart = { content?: string; }; -export type UIFormPart = FormDemands & { +export type UIFormPart = { kind: UIMessagePartKind.Form; + render: FormRender; }; export type UIAuthPart = { @@ -144,6 +146,6 @@ export enum UITransformType { } export interface UIMessageForm { - request: FormDemands; - response: FormFulfillments; + request: FormRender; + response: RunFormValues; } diff --git a/apps/agentstack-ui/src/modules/runs/chat/ChatView.tsx b/apps/agentstack-ui/src/modules/runs/chat/ChatView.tsx index 620734326..753be4f68 100644 --- a/apps/agentstack-ui/src/modules/runs/chat/ChatView.tsx +++ b/apps/agentstack-ui/src/modules/runs/chat/ChatView.tsx @@ -3,10 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { extractServiceExtensionDemands, formExtension } from 'agentstack-sdk'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; -import { getAgentExtensions } from '#api/utils.ts'; import { MainContent } from '#components/layouts/MainContent.tsx'; import type { Agent } from '#modules/agents/api/types.ts'; import { AgentDetailPanel } from '#modules/agents/components/detail/AgentDetailPanel.tsx'; @@ -21,7 +19,6 @@ import { AgentRunProviders } from '../contexts/agent-run/AgentRunProvider'; import { useSyncRunStateWithRoute } from '../hooks/useSyncRunStateWithRoute'; import { ChatMessagesView } from './ChatMessagesView'; -const formExtensionExtractor = extractServiceExtensionDemands(formExtension); interface Props { agent: Agent; } @@ -36,19 +33,11 @@ export function ChatView({ agent }: Props) { } function Chat() { - const { isPending, agent, hasMessages } = useAgentRun(); + const { isPending, agent, hasMessages, initialFormRender } = useAgentRun(); const { contextId } = usePlatformContext(); useSyncRunStateWithRoute(); - // TODO: move extraction into the agent run context (or a2a client) - const formRender = useMemo(() => { - const agentExtensions = getAgentExtensions(agent); - const formRender = formExtensionExtractor(agentExtensions); - - return formRender ?? undefined; - }, [agent]); - const handleMessageSent = useCallback(() => { if (contextId) { window.history.pushState( @@ -68,8 +57,8 @@ function Chat() { <> {isLanding ? ( - formRender ? ( - + initialFormRender ? ( + ) : ( ) diff --git a/apps/agentstack-ui/src/modules/runs/components/FormRenderView.tsx b/apps/agentstack-ui/src/modules/runs/components/FormRenderView.tsx index 00adfe074..94f783b0b 100644 --- a/apps/agentstack-ui/src/modules/runs/components/FormRenderView.tsx +++ b/apps/agentstack-ui/src/modules/runs/components/FormRenderView.tsx @@ -3,17 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FormDemands } from 'agentstack-sdk'; +import type { FormRender } from 'agentstack-sdk'; import { Container } from '#components/layouts/Container.tsx'; import { FormRenderer } from '#modules/form/components/FormRenderer.tsx'; import type { RunFormValues } from '#modules/form/types.ts'; +import { useAgentDemands } from '../contexts/agent-demands'; import { useAgentRun } from '../contexts/agent-run'; import classes from './FormRenderView.module.scss'; interface Props { - formRender: FormDemands; + formRender: FormRender; onMessageSent?: () => void; } @@ -31,11 +32,11 @@ export function FormRenderView({ formRender, onMessageSent }: Props) { showRunSettings onSubmit={(values: RunFormValues) => { onMessageSent?.(); - const form = { + + submitForm({ request: formRender, - response: { id: formRender.id, values }, - }; - submitForm(form); + response: values, + }); }} defaultHeading={agent.ui.user_greeting} /> diff --git a/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/AgentDemandsProvider.tsx b/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/AgentDemandsProvider.tsx index 03a2d0ddf..982e6053e 100644 --- a/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/AgentDemandsProvider.tsx +++ b/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/AgentDemandsProvider.tsx @@ -3,11 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AgentSettings } from 'agentstack-sdk'; -import { type PropsWithChildren, useCallback, useEffect, useState } from 'react'; +import type { AgentSettings, FormFulfillments } from 'agentstack-sdk'; +import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'; import type { AgentA2AClient } from '#api/a2a/types.ts'; import { useApp } from '#contexts/App/index.ts'; +import type { RunFormValues } from '#modules/form/types.ts'; import { useCreateContextToken } from '#modules/platform-context/api/mutations/useCreateContextToken.ts'; import { useMatchProviders } from '#modules/platform-context/api/mutations/useMatchProviders.ts'; import { usePlatformContext } from '#modules/platform-context/contexts/index.ts'; @@ -31,6 +32,8 @@ export function AgentDemandsProvider({ const [selectedEmbeddingProviders, setSelectedEmbeddingProviders] = useState>({}); const [selectedLLMProviders, setSelectedLLMProviders] = useState>({}); + const formFulfillmentsRef = useRef({ form_fulfillments: {} }); + const [selectedSettings, setSelectedSettings] = useState( getSettingsDemandsDefaultValues(agentClient.demands.settingsDemands ?? { fields: [] }), ); @@ -106,6 +109,10 @@ export function AgentDemandsProvider({ [setSelectedEmbeddingProviders], ); + const provideFormValues = useCallback((values: RunFormValues) => { + formFulfillmentsRef.current = { form_fulfillments: { initial_form: { values } } }; + }, []); + const [selectedMCPServers, setSelectedMCPServers] = useState>({}); useEffect(() => { @@ -183,7 +190,7 @@ export function AgentDemandsProvider({ providedSecrets, featureFlags, selectedSettings, - formFulfillments: fulfillmentsContext.formFulfillments ?? null, + formFulfillments: formFulfillmentsRef.current, oauthRedirectUri: fulfillmentsContext.oauthRedirectUri ?? null, }); }, @@ -205,6 +212,7 @@ export function AgentDemandsProvider({ selectedLLMProviders, matchedEmbeddingProviders, selectedEmbeddingProviders, + provideFormValues, getFulfillments, selectLLMProvider, selectEmbeddingProvider, @@ -212,6 +220,7 @@ export function AgentDemandsProvider({ selectedMCPServers, selectedSettings, settingsDemands: agentClient?.demands.settingsDemands ?? null, + formDemands: agentClient?.demands.formDemands ?? null, onUpdateSettings, }} > diff --git a/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/agent-demands-context.ts b/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/agent-demands-context.ts index ddfa4a129..d4d76c909 100644 --- a/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/agent-demands-context.ts +++ b/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/agent-demands-context.ts @@ -3,16 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AgentSettings, FormFulfillments, Fulfillments, SettingsDemands } from 'agentstack-sdk'; +import type { AgentSettings, FormDemands, Fulfillments, SettingsDemands } from 'agentstack-sdk'; import { createContext } from 'react'; +import type { RunFormValues } from '#modules/form/types.ts'; +import type { UIMessageForm } from '#modules/messages/types.ts'; import type { TaskId } from '#modules/tasks/api/types.ts'; export type FulfillmentsContext = Partial<{ taskId: TaskId; providedSecrets: Record; - formFulfillments: FormFulfillments; oauthRedirectUri: string; + form: UIMessageForm; }>; interface AgentDemandsContextValue { @@ -24,11 +26,13 @@ interface AgentDemandsContextValue { selectLLMProvider: (key: string, value: string) => void; selectEmbeddingProvider: (key: string, value: string) => void; selectMCPServer: (key: string, value: string) => void; + provideFormValues: (values: RunFormValues) => void; selectedMCPServers: Record; onUpdateSettings: (settings: AgentSettings) => void; selectedSettings: AgentSettings | undefined; settingsDemands: SettingsDemands | null; + formDemands: FormDemands | null; } export const AgentDemandsContext = createContext(null); diff --git a/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/build-fulfillments.ts b/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/build-fulfillments.ts index 42e780e63..7c55adc7a 100644 --- a/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/build-fulfillments.ts +++ b/apps/agentstack-ui/src/modules/runs/contexts/agent-demands/build-fulfillments.ts @@ -15,7 +15,7 @@ interface BuildFulfillmentsParams { selectedMCPServers: Record; providedSecrets: Record; selectedSettings: AgentSettings; - formFulfillments: FormFulfillments | null; + formFulfillments: FormFulfillments; oauthRedirectUri: string | null; featureFlags: FeatureFlags; } @@ -40,7 +40,11 @@ export const buildFulfillments = ({ }; }, - form: async () => { + form: async (demands) => { + if (demands.form_demands.initial_form && !formFulfillments.form_fulfillments['initial_form']) { + throw new Error('Initial form has not been fulfilled despite being demanded.'); + } + return formFulfillments; }, diff --git a/apps/agentstack-ui/src/modules/runs/contexts/agent-run/AgentRunProvider.tsx b/apps/agentstack-ui/src/modules/runs/contexts/agent-run/AgentRunProvider.tsx index 7f5955c7f..b5e2e1659 100644 --- a/apps/agentstack-ui/src/modules/runs/contexts/agent-run/AgentRunProvider.tsx +++ b/apps/agentstack-ui/src/modules/runs/contexts/agent-run/AgentRunProvider.tsx @@ -89,7 +89,7 @@ function AgentRunProvider({ agent, agentClient, children }: PropsWithChildren(undefined); const { contextId, getContextId, updateContextWithAgentMetadata } = usePlatformContext(); - const { getFulfillments } = useAgentDemands(); + const { getFulfillments, provideFormValues, formDemands } = useAgentDemands(); const { files, clearFiles } = useFileUpload(); const updateCurrentAgentMessage = useCallback( @@ -200,6 +200,9 @@ function AgentRunProvider({ agent, agentClient, children }: PropsWithChildren { message.status = UIMessageStatus.InputRequired; - message.parts.push({ kind: UIMessagePartKind.Form, ...result.form }); + message.parts.push({ kind: UIMessagePartKind.Form, render: result.form }); }); } else if (result && result.type === TaskStatusUpdateType.OAuthRequired) { updateCurrentAgentMessage((message) => { @@ -307,7 +310,7 @@ function AgentRunProvider({ agent, agentClient, children }: PropsWithChildren { checkPendingRun(); + provideFormValues(form.response); + const message: UIUserMessage = { id: uuid(), role: Role.User, @@ -323,9 +328,9 @@ function AgentRunProvider({ agent, agentClient, children }: PropsWithChildren formDemands?.form_demands?.initial_form, [formDemands]); + const contextValue = useMemo(() => { return { agent, @@ -384,6 +391,7 @@ function AgentRunProvider({ agent, agentClient, children }: PropsWithChildren Promise; submitForm: (form: UIMessageForm) => Promise; submitRuntimeForm: (form: UIMessageForm, taskId: TaskId) => Promise; diff --git a/apps/agentstack-ui/src/modules/runs/hands-off/HandsOffView.tsx b/apps/agentstack-ui/src/modules/runs/hands-off/HandsOffView.tsx index fc933fa36..66c448252 100644 --- a/apps/agentstack-ui/src/modules/runs/hands-off/HandsOffView.tsx +++ b/apps/agentstack-ui/src/modules/runs/hands-off/HandsOffView.tsx @@ -3,10 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { extractServiceExtensionDemands, formExtension } from 'agentstack-sdk'; -import { useMemo } from 'react'; - -import { getAgentExtensions } from '#api/utils.ts'; import { MainContent } from '#components/layouts/MainContent.tsx'; import type { Agent } from '#modules/agents/api/types.ts'; import { AgentDetailPanel } from '#modules/agents/components/detail/AgentDetailPanel.tsx'; @@ -19,8 +15,6 @@ import { useAgentRun } from '../contexts/agent-run'; import { AgentRunProviders } from '../contexts/agent-run/AgentRunProvider'; import { HandsOffOutputView } from './HandsOffOutputView'; -const formExtensionExtractor = extractServiceExtensionDemands(formExtension); - interface Props { agent: Agent; } @@ -35,16 +29,7 @@ export function HandsOffView({ agent }: Props) { } function HandsOff() { - const { agent, isPending } = useAgentRun(); - - // TODO: move extraction into the agent run context (or a2a client) - const formRender = useMemo(() => { - const agentExtensions = getAgentExtensions(agent); - const formRender = formExtensionExtractor(agentExtensions); - - return formRender ?? undefined; - }, [agent]); - + const { isPending, initialFormRender } = useAgentRun(); const { messages } = useMessages(); const isIdle = !(isPending || messages?.length); @@ -52,7 +37,15 @@ function HandsOff() { return ( <> - {isIdle ? formRender ? : : } + {isIdle ? ( + initialFormRender ? ( + + ) : ( + + ) + ) : ( + + )} diff --git a/apps/agentstack-ui/src/utils/feature-flags.ts b/apps/agentstack-ui/src/utils/feature-flags.ts index 5f6114541..7da836aa8 100644 --- a/apps/agentstack-ui/src/utils/feature-flags.ts +++ b/apps/agentstack-ui/src/utils/feature-flags.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { z } from 'zod'; +import z from 'zod'; const booleanProp = (defaultValue: boolean | undefined = false) => z.boolean().optional().default(defaultValue); diff --git a/docs/sdk/forms.mdx b/docs/sdk/forms.mdx index 539f36c8d..2fa48a48d 100644 --- a/docs/sdk/forms.mdx +++ b/docs/sdk/forms.mdx @@ -5,59 +5,62 @@ description: "Collect structured input from users" One of the most powerful features of the Agent Stack is the ability to request structured data from users through interactive forms. Instead of relying on free-form text input, your agent can present users with specific fields, dropdowns, and other form elements to gather precise information. -The Agent Stack provides a Form extension that allows you to collect structured data from users in two ways: +The Agent Stack provides a Form extensions that allows you to collect structured data from users in two ways: 1. **Initial form rendering** - Present a form as the first interaction before users start a conversation with your agent 2. **Dynamic form requests** - Request forms at any point during a multi-turn conversation when your agent needs specific structured input +## Initial Form Rendering + +For initial form rendering, you specify the form structure when injecting the extension and then parse the response using a Pydantic model. The form is presented to users before they start a conversation with your agent. -## Quickstart +### Quickstart - -Import the necessary components from the Agent Stack SDK form extension. + +Import `FormServiceExtensionServer`, `FormServiceExtensionSpec`, `FormRender`, and field types from the Agent Stack SDK. - -Inject the Form extension into your agent function using the `Annotated` type hint. + +Create a Pydantic model with fields matching your form field IDs. - -Create a `FormRender` object with the fields you want to collect from users. + +Inject the form extension into your agent function using `FormServiceExtensionSpec.demand(initial_form=FormRender(...))`. - -Use either `parse_form_response()` for initial forms or `request_form()` for dynamic forms. + +Call `form.parse_initial_form(model=YourModel)` to extract the submitted form data. -## Initial Form Rendering - -For initial form rendering, you specify the form structure when injecting the extension and then parse the response from the initial message: - ```python -import os from typing import Annotated from a2a.types import Message +from pydantic import BaseModel from agentstack_sdk.server import Server -from agentstack_sdk.a2a.extensions.ui.form import ( - FormExtensionServer, - FormExtensionSpec, - FormRender, - TextField +from agentstack_sdk.a2a.extensions.common.form import FormRender, TextField +from agentstack_sdk.a2a.extensions.services.form import ( + FormServiceExtensionServer, + FormServiceExtensionSpec, ) server = Server() + +class UserInfo(BaseModel): + first_name: str | None + last_name: str | None + + @server.agent() async def initial_form_agent( - message: Message, + _message: Message, form: Annotated[ - FormExtensionServer, - FormExtensionSpec( - params=FormRender( - id="user_info_form", + FormServiceExtensionServer, + FormServiceExtensionSpec.demand( + initial_form=FormRender( title="Welcome! Please tell us about yourself", columns=2, fields=[ @@ -70,48 +73,71 @@ async def initial_form_agent( ): """Agent that collects user information through an initial form""" - # Parse the form data from the initial message - form_data = form.parse_form_response(message=message) - - # Access the form values - first_name = form_data.values['first_name'].value - last_name = form_data.values['last_name'].value + # Parse the form data using a Pydantic model + user_info = form.parse_initial_form(model=UserInfo) - yield f"Hello {first_name} {last_name}! Nice to meet you." + if user_info is None: + yield "No form data received." + else: + yield f"Hello {user_info.first_name} {user_info.last_name}! Nice to meet you." -def run(): - server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 8000))) if __name__ == "__main__": - run() + server.run() ``` ## Dynamic Form Requests -For dynamic form requests during conversation, you can request forms at any point when your agent needs structured input. This is useful when your agent needs to collect additional information based on the conversation flow: +For dynamic form requests during conversation, you can request forms at any point when your agent needs structured input. This is useful when your agent needs to collect additional information based on the conversation flow. + +### Quickstart + + + +Import `FormRequestExtensionServer`, `FormRequestExtensionSpec`, `FormRender`, and field types from the Agent Stack SDK. + + + +Create a Pydantic model with fields matching your form field IDs. + + + +Inject the request form extension into your agent function using `FormRequestExtensionSpec()`. + + + +Call `await form_request.request_form(form=FormRender(...), model=YourModel)` when you need to collect structured input. + + ```python -import os from typing import Annotated from a2a.types import Message from a2a.utils.message import get_message_text +from pydantic import BaseModel from agentstack_sdk.server import Server -from agentstack_sdk.a2a.extensions.ui.form import ( - FormExtensionServer, - FormExtensionSpec, - FormRender, - TextField +from agentstack_sdk.a2a.extensions.common.form import FormRender, TextField +from agentstack_sdk.a2a.extensions.ui.form_request import ( + FormRequestExtensionServer, + FormRequestExtensionSpec, ) server = Server() + +class ContactInfo(BaseModel): + email: str | None + phone: str | None + company: str | None + + @server.agent() async def dynamic_form_agent( message: Message, - form: Annotated[ - FormExtensionServer, - FormExtensionSpec(params=None) + form_request: Annotated[ + FormRequestExtensionServer, + FormRequestExtensionSpec(), ], ): """Agent that requests forms dynamically during conversation""" @@ -121,9 +147,8 @@ async def dynamic_form_agent( # Check if user wants to provide contact information if "contact" in user_input.lower() or "reach" in user_input.lower(): # Request contact form dynamically - form_data = await form.request_form( + contact_info = await form_request.request_form( form=FormRender( - id="contact_form", title="Please provide your contact information", columns=2, fields=[ @@ -131,45 +156,38 @@ async def dynamic_form_agent( TextField(id="phone", label="Phone Number", col_span=1), TextField(id="company", label="Company", col_span=1), ], - ) + ), + model=ContactInfo, ) - email = form_data.values['email'].value - phone = form_data.values['phone'].value - company = form_data.values['company'].value - - yield f"Thank you! I'll contact you at {email} or {phone} regarding {company}." + if contact_info is None: + yield "No contact information received." + else: + yield f"Thank you! I'll contact you at {contact_info.email} or {contact_info.phone} regarding {contact_info.company}." else: yield "Hello! If you'd like me to contact you, just let me know and I'll ask for your details." -def run(): - server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 8000))) if __name__ == "__main__": - run() + server.run() ``` ## How to work with forms Here's what you need to know to add form capabilities to your agent: -**Import the form extension**: Import `FormExtensionServer`, `FormExtensionSpec`, `FormRender`, and field types from `agentstack_sdk.a2a.extensions.ui.form`. - -**Inject the extension**: Add a form parameter to your agent function using the `Annotated` type hint with `FormExtensionServer` and `FormExtensionSpec`. +**Import the form components**: +- For form fields and `FormRender`, import from `agentstack_sdk.a2a.extensions.common.form` +- For initial forms, import `FormServiceExtensionServer` and `FormServiceExtensionSpec` from `agentstack_sdk.a2a.extensions.services.form` +- For dynamic forms, import `FormRequestExtensionServer` and `FormRequestExtensionSpec` from `agentstack_sdk.a2a.extensions.ui.form_request` -**For initial forms**: Specify the form structure in the `FormExtensionSpec` parameters and use `parse_form_response()` to extract data from the initial message. +**Inject the extension**: Add a form parameter to your agent function using the `Annotated` type hint. -**For dynamic forms**: Use an empty `FormExtensionSpec()` and call `await form.request_form()` with your form definition when needed. +**For initial forms**: Use `FormServiceExtensionSpec.demand(initial_form=FormRender(...))` to specify the form structure and call `form.parse_initial_form(model=YourModel)` to extract data. -**Access form data**: Use `form_data.values['field_id'].value` to access the submitted values from your form fields. Different field types return different value types: - -- **TextField/DateField**: Returns `str | None` -- **FileField**: Returns `list[FileInfo] | None` where each `FileInfo` has `uri`, `name`, and `mime_type` -- **SingleSelectField**: Returns `str | None` (selected option ID) -- **MultiSelectField**: Returns `list[str] | None` (list of selected option IDs) -- **CheckboxField**: Returns `bool | None` +**For dynamic forms**: Use `FormRequestExtensionSpec()` and call `await form_request.request_form(form=FormRender(...), model=YourModel)` when needed. -As a convenient shortcut, you may use a custom model (Pydantic model, `TypedDict`, `dataclass`, or any class supported by `pydantic.TypeAdapter`) to load form data. For example, we can define a following model: +**Access form data**: The recommended approach is to use a Pydantic model (or `TypedDict`, `dataclass`, or any class supported by `pydantic.TypeAdapter`) to load form data. Define a model with fields matching your form field IDs: ```python from pydantic import BaseModel @@ -180,12 +198,27 @@ class ContactInfo(BaseModel): company: str | None ``` -Then, in agent code, pass `model=ContactInfo` to `parse_form_response(...)` or `request_form(...)` to get the form data directly as an instance of `ContactInfo`: +Then, pass `model=ContactInfo` to `parse_initial_form(...)` or `request_form(...)` to get the form data directly as an instance of `ContactInfo`: ```python -contact_info: ContactInfo = form.parse_form_response(message=message, model=ContactInfo) +# For initial forms +contact_info: ContactInfo | None = form.parse_initial_form(model=ContactInfo) + +# For dynamic forms +contact_info: ContactInfo | None = await form_request.request_form( + form=FormRender(...), + model=ContactInfo +) ``` +If you don't use a model, the methods return `FormResponse` which has a `values` dictionary. You can access values using `form_data.values['field_id'].value`. Different field types return different value types: + +- **TextField/DateField**: Returns `str | None` +- **FileField**: Returns `list[FileInfo] | None` where each `FileInfo` has `uri`, `name`, and `mime_type` +- **SingleSelectField**: Returns `str | None` (selected option ID) +- **MultiSelectField**: Returns `list[str] | None` (list of selected option IDs) +- **CheckboxField**: Returns `bool | None` + ## Form Field Types The Agent Stack supports various field types for collecting different kinds of structured data: @@ -194,7 +227,7 @@ The Agent Stack supports various field types for collecting different kinds of s Basic text input fields for collecting strings, names, descriptions, etc. ```python -from agentstack_sdk.a2a.extensions.ui.form import TextField +from agentstack_sdk.a2a.extensions.common.form import TextField TextField( id="username", @@ -202,7 +235,8 @@ TextField( col_span=1, required=True, placeholder="Enter your username", - default_value="" + default_value="", + type="text" # Optional, defaults to "text" ) ``` @@ -210,7 +244,7 @@ TextField( Date input fields for collecting dates and timestamps. ```python -from agentstack_sdk.a2a.extensions.ui.form import DateField +from agentstack_sdk.a2a.extensions.common.form import DateField DateField( id="birth_date", @@ -226,7 +260,7 @@ DateField( File upload fields for collecting files from users. ```python -from agentstack_sdk.a2a.extensions.ui.form import FileField +from agentstack_sdk.a2a.extensions.common.form import FileField FileField( id="document", @@ -241,7 +275,7 @@ FileField( Single-select dropdown fields for choosing single option from a list. ```python -from agentstack_sdk.a2a.extensions.ui.form import OptionItem, SingleSelectField +from agentstack_sdk.a2a.extensions.common.form import OptionItem, SingleSelectField SingleSelectField( id="contact_method", @@ -262,7 +296,7 @@ SingleSelectField( Multi-select dropdown fields for choosing multiple options from a list. ```python -from agentstack_sdk.a2a.extensions.ui.form import OptionItem, MultiSelectField +from agentstack_sdk.a2a.extensions.common.form import OptionItem, MultiSelectField MultiSelectField( id="interests", @@ -283,7 +317,7 @@ MultiSelectField( Single checkbox fields for boolean values. ```python -from agentstack_sdk.a2a.extensions.ui.form import CheckboxField +from agentstack_sdk.a2a.extensions.common.form import CheckboxField CheckboxField( id="newsletter", @@ -300,8 +334,9 @@ CheckboxField( Control how your form appears using the `FormRender` configuration: ```python +from agentstack_sdk.a2a.extensions.common.form import FormRender + FormRender( - id="my_form", title="Form Title", description="Optional description text below the title", columns=2, # Number of columns in the form grid @@ -313,11 +348,10 @@ FormRender( ``` **FormRender properties**: -- **`id`**: Unique identifier for the form (required) -- **`title`**: Main heading displayed above the form +- **`title`**: Main heading displayed above the form (optional) - **`description`**: Optional description text displayed below the title -- **`columns`**: Number of columns in the form grid (1-4) -- **`submit_label`**: Custom text for the submit button (default: "Submit") +- **`columns`**: Number of columns in the form grid (1-4, optional) +- **`submit_label`**: Custom text for the submit button (optional, default: "Submit") - **`fields`**: List of form field definitions (required) diff --git a/docs/sdk/overview.mdx b/docs/sdk/overview.mdx index 627d870a1..49d8f470a 100644 --- a/docs/sdk/overview.mdx +++ b/docs/sdk/overview.mdx @@ -53,27 +53,28 @@ When you await a form request, execution pauses the task, allowing the user to f ```python from typing import Annotated -from agentstack_sdk.a2a.extensions.ui.form import ( - FormExtensionServer, - FormExtensionSpec, +from agentstack_sdk.a2a.extensions.common.form import ( FormRender, TextField ) +from agentstack_sdk.a2a.extensions.ui.form_request import ( + FormRequestExtensionServer, + FormRequestExtensionSpec, +) @server.agent() async def form_agent( input: Message, context: RunContext, - form: Annotated[FormExtensionServer, FormExtensionSpec(params=None)] + form_request: Annotated[FormRequestExtensionServer, FormRequestExtensionSpec()] ): """Agent that pauses execution to request user input""" yield AgentMessage(text="I need some information from you.") # Execution pauses here - task enters input_required state # User fills out the form in the UI - form_data = await form.request_form( + form_data = await form_request.request_form( form=FormRender( - id="user_info", title="Please provide your details", fields=[ TextField(id="name", label="Your Name"),