From 37f781ba0d90572aab103bad06b4920ee8c5acb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Bula=CC=81nek?= Date: Thu, 29 Jan 2026 17:29:02 +0100 Subject: [PATCH 1/4] feat(sdk): add checkbox group to form extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Petr Bulánek --- .../a2a/extensions/common/form/schemas.ts | 13 +++++ .../a2a/extensions/common/form/types.ts | 4 ++ .../a2a/extensions/services/form/schemas.ts | 41 +++++++++++++- .../a2a/extensions/services/form/types.ts | 18 +++++- .../src/modules/form/components/FormField.tsx | 2 + .../fields/CheckboxGroupField.module.scss | 14 +++++ .../components/fields/CheckboxGroupField.tsx | 55 +++++++++++++++++++ .../fields/CheckboxGroupFieldItem.tsx | 34 ++++++++++++ .../src/modules/form/constants.ts | 2 + .../form/hooks/useFormFieldValidation.ts | 10 +++- apps/agentstack-ui/src/modules/form/utils.ts | 1 + .../components/MessageFormResponse.tsx | 9 +++ 12 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.module.scss create mode 100644 apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.tsx create mode 100644 apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupFieldItem.tsx diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts index 9f2611e3b..3f4d3ccc2 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts @@ -53,6 +53,12 @@ export const checkboxFieldSchema = baseFieldSchema.extend({ default_value: z.boolean().nullish(), }); +export const checkboxGroupFieldSchema = baseFieldSchema.extend({ + type: z.literal('checkbox_group'), + fields: z.array(checkboxFieldSchema), + default_value: z.record(z.string(), z.boolean()).nullish(), +}); + export const formFieldSchema = z.discriminatedUnion('type', [ textFieldSchema, dateFieldSchema, @@ -60,6 +66,7 @@ export const formFieldSchema = z.discriminatedUnion('type', [ singleSelectFieldSchema, multiSelectFieldSchema, checkboxFieldSchema, + checkboxGroupFieldSchema, ]); export const textFieldValueSchema = z.object({ @@ -100,6 +107,11 @@ export const checkboxFieldValueSchema = z.object({ value: z.boolean().nullish(), }); +export const checkboxGroupFieldValueSchema = z.object({ + type: checkboxGroupFieldSchema.shape.type, + value: z.record(z.string(), z.boolean()).nullish(), +}); + export const formFieldValueSchema = z.discriminatedUnion('type', [ textFieldValueSchema, dateFieldValueSchema, @@ -107,6 +119,7 @@ export const formFieldValueSchema = z.discriminatedUnion('type', [ singleSelectFieldValueSchema, multiSelectFieldValueSchema, checkboxFieldValueSchema, + checkboxGroupFieldValueSchema, ]); export const formRenderSchema = z.object({ diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/types.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/types.ts index 3f659bdc6..27b75b1d4 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/types.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/types.ts @@ -8,6 +8,8 @@ import type z from 'zod'; import type { checkboxFieldSchema, checkboxFieldValueSchema, + checkboxGroupFieldSchema, + checkboxGroupFieldValueSchema, dateFieldSchema, dateFieldValueSchema, fileFieldSchema, @@ -33,6 +35,7 @@ export type SelectFieldOption = z.infer; export type SingleSelectField = z.infer; export type MultiSelectField = z.infer; export type CheckboxField = z.infer; +export type CheckboxGroupField = z.infer; export type FormField = z.infer; @@ -42,6 +45,7 @@ export type FileFieldValue = z.infer; export type SingleSelectFieldValue = z.infer; export type MultiSelectFieldValue = z.infer; export type CheckboxFieldValue = z.infer; +export type CheckboxGroupFieldValue = z.infer; export type FormFieldValue = z.infer; diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/schemas.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/schemas.ts index b21cd8a34..60760c497 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/schemas.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/schemas.ts @@ -5,16 +5,51 @@ import z from 'zod'; -import { formRenderSchema, formResponseSchema } from '../../common/form/schemas'; +import { + checkboxGroupFieldSchema, + checkboxGroupFieldValueSchema, + formRenderSchema, + formResponseSchema, + singleSelectFieldSchema, + singleSelectFieldValueSchema, +} from '../../common/form/schemas'; + +export const settingsFormFieldSchema = z.discriminatedUnion('type', [ + checkboxGroupFieldSchema, + singleSelectFieldSchema, +]); + +export const settingsFormFieldValueSchema = z.discriminatedUnion('type', [ + checkboxGroupFieldValueSchema, + singleSelectFieldValueSchema, +]); + +export const settingsFormRenderSchema = formRenderSchema.extend({ + fields: z.array(settingsFormFieldSchema).nonempty(), +}); + +export const settingsFormValuesSchema = z.record(z.string(), settingsFormFieldValueSchema); + +export const settingsFormResponseSchema = formResponseSchema.extend({ + values: settingsFormValuesSchema, +}); export const formDemandsSchema = z.object({ form_demands: z .object({ initial_form: formRenderSchema, + settings_form: settingsFormRenderSchema, }) - .partial(), + .partial() + .catchall(formRenderSchema), }); export const formFulfillmentsSchema = z.object({ - form_fulfillments: z.record(z.string(), formResponseSchema), + form_fulfillments: z + .object({ + initial_form: formResponseSchema, + settings_form: settingsFormResponseSchema, + }) + .partial() + .catchall(formResponseSchema), }); diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/types.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/types.ts index 2c35a0f2f..99d2f22aa 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/types.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/services/form/types.ts @@ -5,8 +5,22 @@ import type z from 'zod'; -import type { formDemandsSchema, formFulfillmentsSchema } from './schemas'; +import type { + formDemandsSchema, + formFulfillmentsSchema, + settingsFormFieldSchema, + settingsFormFieldValueSchema, + settingsFormRenderSchema, + settingsFormResponseSchema, + settingsFormValuesSchema, +} from './schemas'; -export type FormDemands = z.infer; +export type SettingsFormField = z.infer; +export type SettingsFormFieldValue = z.infer; + +export type SettingsFormRender = z.infer; +export type SettingsFormValues = z.infer; +export type SettingsFormResponse = z.infer; +export type FormDemands = z.infer; export type FormFulfillments = z.infer; diff --git a/apps/agentstack-ui/src/modules/form/components/FormField.tsx b/apps/agentstack-ui/src/modules/form/components/FormField.tsx index bcc1c599a..107524151 100644 --- a/apps/agentstack-ui/src/modules/form/components/FormField.tsx +++ b/apps/agentstack-ui/src/modules/form/components/FormField.tsx @@ -8,6 +8,7 @@ import type { CSSProperties } from 'react'; import { match, P } from 'ts-pattern'; import { CheckboxField } from './fields/CheckboxField'; +import { CheckboxGroupField } from './fields/CheckboxGroupField'; import { DateField } from './fields/DateField'; import { FileField } from './fields/FileField'; import { FileFieldValue } from './fields/FileFieldValue'; @@ -35,6 +36,7 @@ export function FormField({ field, value }: Props) { .with({ type: 'singleselect' }, (field) => ) .with({ type: 'multiselect' }, (field) => ) .with({ type: 'checkbox' }, (field) => ) + .with({ type: 'checkbox_group' }, (field) => ) .exhaustive(); return ( diff --git a/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.module.scss b/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.module.scss new file mode 100644 index 000000000..72f063061 --- /dev/null +++ b/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.module.scss @@ -0,0 +1,14 @@ +/** + * Copyright 2025 © BeeAI a Series of LF Projects, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +.root { + > :global(.cds--checkbox-group__validation-msg) { + margin-block-start: 0; + } + + :global(.cds--checkbox-wrapper--invalid > .cds--checkbox__validation-msg) { + display: flex; + } +} diff --git a/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.tsx b/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.tsx new file mode 100644 index 000000000..1b5a46c1e --- /dev/null +++ b/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupField.tsx @@ -0,0 +1,55 @@ +/** + * Copyright 2025 © BeeAI a Series of LF Projects, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CheckboxGroup } from '@carbon/react'; +import type { CheckboxGroupField, CheckboxGroupFieldValue } from 'agentstack-sdk'; +import { useController, useFormContext } from 'react-hook-form'; + +import { REQUIRED_GROUP_ERROR_MESSAGE } from '#modules/form/constants.ts'; +import { useFormFieldValidation } from '#modules/form/hooks/useFormFieldValidation.ts'; +import type { ValuesOfField } from '#modules/form/types.ts'; +import { getFieldName } from '#modules/form/utils.ts'; + +import classes from './CheckboxGroupField.module.scss'; +import { CheckboxGroupFieldItem } from './CheckboxGroupFieldItem'; + +interface Props { + field: CheckboxGroupField; +} + +export function CheckboxGroupField({ field }: Props) { + const { label, fields, required } = field; + const name = getFieldName(field); + + const { control, formState } = useFormContext>(); + const { + rules, + invalid: invalidState, + invalidText, + } = useFormFieldValidation({ + field, + formState, + rules: { + validate: (value: CheckboxGroupFieldValue['value']) => { + if (!required) { + return true; + } + + return Object.values(value ?? {}).some(Boolean) || REQUIRED_GROUP_ERROR_MESSAGE; + }, + }, + }); + const invalid = invalidState && Boolean(invalidText); + + useController({ control, name, rules }); + + return ( + + {fields.map((itemField) => ( + + ))} + + ); +} diff --git a/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupFieldItem.tsx b/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupFieldItem.tsx new file mode 100644 index 000000000..e9c558163 --- /dev/null +++ b/apps/agentstack-ui/src/modules/form/components/fields/CheckboxGroupFieldItem.tsx @@ -0,0 +1,34 @@ +/** + * Copyright 2025 © BeeAI a Series of LF Projects, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Checkbox } from '@carbon/react'; +import type { CheckboxField, CheckboxGroupField } from 'agentstack-sdk'; +import { useFormContext } from 'react-hook-form'; + +import { useFormFieldValidation } from '#modules/form/hooks/useFormFieldValidation.ts'; +import type { ValuesOfField } from '#modules/form/types.ts'; +import { getFieldName } from '#modules/form/utils.ts'; + +interface Props { + field: CheckboxField; + groupField: CheckboxGroupField; +} + +export function CheckboxGroupFieldItem({ field, groupField }: Props) { + const { id, content } = field; + + const groupName = getFieldName(groupField); + const name = `${groupName}.${id}`; + + const { register, formState } = useFormContext>(); + const { rules, invalid, invalidText } = useFormFieldValidation({ field, formState, name }); + + const inputProps = register(name, { + ...rules, + deps: groupName, + }); + + return ; +} diff --git a/apps/agentstack-ui/src/modules/form/constants.ts b/apps/agentstack-ui/src/modules/form/constants.ts index cf31af11f..321838354 100644 --- a/apps/agentstack-ui/src/modules/form/constants.ts +++ b/apps/agentstack-ui/src/modules/form/constants.ts @@ -4,3 +4,5 @@ */ export const REQUIRED_ERROR_MESSAGE = 'This field is required.'; + +export const REQUIRED_GROUP_ERROR_MESSAGE = 'Please select at least one option.'; diff --git a/apps/agentstack-ui/src/modules/form/hooks/useFormFieldValidation.ts b/apps/agentstack-ui/src/modules/form/hooks/useFormFieldValidation.ts index 8d6ec0a34..5838be2a0 100644 --- a/apps/agentstack-ui/src/modules/form/hooks/useFormFieldValidation.ts +++ b/apps/agentstack-ui/src/modules/form/hooks/useFormFieldValidation.ts @@ -5,7 +5,7 @@ import type { FormField } from 'agentstack-sdk'; import get from 'lodash/get'; -import type { FormState } from 'react-hook-form'; +import type { FormState, RegisterOptions } from 'react-hook-form'; import { REQUIRED_ERROR_MESSAGE } from '../constants'; import type { ValuesOfField } from '../types'; @@ -14,18 +14,24 @@ import { getFieldName } from '../utils'; export function useFormFieldValidation({ field, formState, + name, + rules: customRules, }: { field: F; formState: FormState>; + name?: string; + rules?: RegisterOptions; }) { const { required } = field; - const error = get(formState.errors, getFieldName(field)); + const fieldName = name ?? getFieldName(field); + const error = get(formState.errors, fieldName); const invalid = Boolean(error); const invalidText = error?.message; const rules = { required: Boolean(required) && REQUIRED_ERROR_MESSAGE, + ...customRules, }; return { diff --git a/apps/agentstack-ui/src/modules/form/utils.ts b/apps/agentstack-ui/src/modules/form/utils.ts index f5ee1e536..f6b7f2c6d 100644 --- a/apps/agentstack-ui/src/modules/form/utils.ts +++ b/apps/agentstack-ui/src/modules/form/utils.ts @@ -21,6 +21,7 @@ export function getDefaultValues(fields: FormField[]) { { type: 'singleselect' }, { type: 'multiselect' }, { type: 'checkbox' }, + { type: 'checkbox_group' }, ({ id, type, default_value }) => [id, { type, value: default_value }], ) .otherwise(({ id, type }) => [id, { type }]), diff --git a/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx b/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx index 23dfbe1f1..6e9a7e7a1 100644 --- a/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx +++ b/apps/agentstack-ui/src/modules/messages/components/MessageFormResponse.tsx @@ -6,6 +6,7 @@ import { Button } from '@carbon/react'; import type { CheckboxField, + CheckboxGroupField, DateField, FileField, FormField, @@ -87,6 +88,13 @@ function FormValueRenderer({ field }: { field: FieldWithValue }) { {match(field) .with({ type: 'text' }, { type: 'date' }, ({ value }) => value) .with({ type: 'checkbox' }, ({ value }) => (value ? 'yes' : 'no')) + .with({ type: 'checkbox_group' }, ({ value }) => + value + ? Object.entries(value) + .map(([id, value]) => `${id} - ${value ? 'yes' : 'no'}`) + .join(', ') + : null, + ) .with({ type: 'singleselect' }, ({ value }) => value) .with({ type: 'multiselect' }, ({ value }) => value?.join(', ')) .with({ type: 'file' }, ({ value }) => value?.map(({ name }) => name).join(', ')) @@ -100,6 +108,7 @@ type FieldWithValue = | FieldWithValueMapper | FieldWithValueMapper | FieldWithValueMapper + | FieldWithValueMapper | FieldWithValueMapper | FieldWithValueMapper | FieldWithValueMapper; From a811611e60fcea1467126eb37a9b2bb4a4693989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Bula=CC=81nek?= Date: Fri, 30 Jan 2026 17:31:34 +0100 Subject: [PATCH 2/4] feat(ui): unify settings with form extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Petr Bulánek --- .../a2a/extensions/common/form/schemas.ts | 3 +- .../a2a/extensions/ui/settings/index.ts | 3 + .../src/client/core/extensions/types.ts | 3 + apps/agentstack-ui/src/modules/form/utils.ts | 21 +++- .../agent-demands/AgentDemandsProvider.tsx | 118 ++++++++++++------ .../agent-demands/agent-demands-context.ts | 23 +++- .../agent-demands/build-fulfillments.ts | 37 ++++-- .../contexts/agent-run/AgentRunProvider.tsx | 5 +- .../src/modules/runs/settings/RunSettings.tsx | 6 +- .../modules/runs/settings/RunSettingsForm.tsx | 40 +++--- .../settings/SingleSelectSettingsField.tsx | 28 +++-- .../runs/settings/ToggleSettingsField.tsx | 28 +++-- .../src/modules/runs/settings/utils.ts | 110 ++++++++++++++-- apps/agentstack-ui/src/utils/helpers.ts | 6 - 14 files changed, 305 insertions(+), 126 deletions(-) diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts index 3f4d3ccc2..d138fccd4 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/common/form/schemas.ts @@ -56,7 +56,6 @@ export const checkboxFieldSchema = baseFieldSchema.extend({ export const checkboxGroupFieldSchema = baseFieldSchema.extend({ type: z.literal('checkbox_group'), fields: z.array(checkboxFieldSchema), - default_value: z.record(z.string(), z.boolean()).nullish(), }); export const formFieldSchema = z.discriminatedUnion('type', [ @@ -109,7 +108,7 @@ export const checkboxFieldValueSchema = z.object({ export const checkboxGroupFieldValueSchema = z.object({ type: checkboxGroupFieldSchema.shape.type, - value: z.record(z.string(), z.boolean()).nullish(), + value: z.record(z.string(), z.boolean().nullish()).nullish(), }); export const formFieldValueSchema = z.discriminatedUnion('type', [ diff --git a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings/index.ts b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings/index.ts index 874d76058..735e47338 100644 --- a/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings/index.ts +++ b/apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/settings/index.ts @@ -9,6 +9,9 @@ import type { SettingsDemands, SettingsFulfillments } from './types'; const URI = 'https://a2a-extensions.agentstack.beeai.dev/ui/settings/v1'; +/** + * @deprecated Use the form extension with `form_demands.settings_form`. + */ export const settingsExtension: A2AServiceExtension = { getUri: () => URI, getDemandsSchema: () => settingsDemandsSchema, diff --git a/apps/agentstack-sdk-ts/src/client/core/extensions/types.ts b/apps/agentstack-sdk-ts/src/client/core/extensions/types.ts index c73182dea..6e48ac175 100644 --- a/apps/agentstack-sdk-ts/src/client/core/extensions/types.ts +++ b/apps/agentstack-sdk-ts/src/client/core/extensions/types.ts @@ -35,6 +35,9 @@ export type Fulfillments = Partial<{ embedding: (demand: EmbeddingDemands) => Promise; mcp: (demand: MCPDemands) => Promise; oauth: (demand: OAuthDemands) => Promise; + /** + * @deprecated - keeping this for backwards compatibility, use form extension with "settings_form" demand instead + */ settings: (demand: SettingsDemands) => Promise; secrets: (demand: SecretDemands) => Promise; form: (demand: FormDemands) => Promise; diff --git a/apps/agentstack-ui/src/modules/form/utils.ts b/apps/agentstack-ui/src/modules/form/utils.ts index f6b7f2c6d..4b278b193 100644 --- a/apps/agentstack-ui/src/modules/form/utils.ts +++ b/apps/agentstack-ui/src/modules/form/utils.ts @@ -4,6 +4,8 @@ */ import type { FormField } from 'agentstack-sdk'; +import keyBy from 'lodash/keyBy'; +import mapValues from 'lodash/mapValues'; import { match } from 'ts-pattern'; import { getFilePlatformUrl } from '#api/a2a/utils.ts'; @@ -21,9 +23,24 @@ export function getDefaultValues(fields: FormField[]) { { type: 'singleselect' }, { type: 'multiselect' }, { type: 'checkbox' }, - { type: 'checkbox_group' }, - ({ id, type, default_value }) => [id, { type, value: default_value }], + ({ id, type, default_value }) => [ + id, + { + type, + value: default_value, + }, + ], ) + .with({ type: 'checkbox_group' }, ({ id, type, fields }) => [ + id, + { + type, + value: mapValues( + keyBy(fields, ({ id }) => id), + ({ default_value }) => default_value, + ), + }, + ]) .otherwise(({ id, type }) => [id, { type }]), ), ); 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 f157492aa..a52bd2244 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,17 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type FormFulfillments, ModelCapability, type SettingsValues } from 'agentstack-sdk'; +import type { FormFulfillments, SettingsFormRender, SettingsFormValues } from 'agentstack-sdk'; +import { ModelCapability } from 'agentstack-sdk'; +import mapValues from 'lodash/mapValues'; import { type PropsWithChildren, useCallback, useMemo, useRef, useState } from 'react'; import { useListConnectors } from '#modules/connectors/api/queries/useListConnectors.ts'; -import type { RunFormValues } from '#modules/form/types.ts'; import { useMatchModelProviders } from '#modules/platform-context/api/mutations/useMatchModelProviders.ts'; -import { getSettingsDemandsDefaultValues } from '#modules/runs/settings/utils.ts'; +import { + getInitialSettingsFormValues, + transformLegacySettingsDemandsToSettingsForm, +} from '#modules/runs/settings/utils.ts'; import { useA2AClient } from '../a2a-client'; import { useAgentSecrets } from '../agent-secrets'; -import type { FulfillmentsContext } from './agent-demands-context'; +import type { FulfillmentsContext, ProvideFormValuesParams } from './agent-demands-context'; import { AgentDemandsContext } from './agent-demands-context'; import { buildFulfillments } from './build-fulfillments'; @@ -23,28 +27,38 @@ export function AgentDemandsProvider({ children }: PropsWithChildren) { const [selectedEmbeddingProviders, setSelectedEmbeddingProviders] = useState>({}); const [selectedLLMProviders, setSelectedLLMProviders] = useState>({}); - const formFulfillmentsRef = useRef({ form_fulfillments: {} }); - const [selectedSettings, setSelectedSettings] = useState( - getSettingsDemandsDefaultValues(agentClient.demands.settingsDemands ?? { fields: [] }), - ); + const legacySettingsDemands = agentClient.demands.settingsDemands; + const formDemands = agentClient.demands.formDemands; + const settingsFormDemand = formDemands?.form_demands.settings_form; + const settingsFormDemanded = Boolean(settingsFormDemand); + + const settingsForm: SettingsFormRender | null = + formDemands?.form_demands.settings_form ?? transformLegacySettingsDemandsToSettingsForm(legacySettingsDemands); + + const initialSettingsFormValues = getInitialSettingsFormValues(settingsForm); + const formFulfillmentsRef = useRef({ + form_fulfillments: settingsFormDemanded + ? { + settings_form: { + values: initialSettingsFormValues, + }, + } + : {}, + }); - const onUpdateSettings = useCallback((value: SettingsValues) => { - setSelectedSettings(value); - }, []); + const [selectedSettings, setSelectedSettings] = useState(initialSettingsFormValues); const setDefaultSelectedLLMProviders = useCallback( (data: Record) => { setSelectedLLMProviders( - Object.fromEntries( - Object.entries(data).map(([key, value]) => { - if (value.length === 0) { - throw new Error(`No match found for demand ${key}`); - } - - return [key, value[0]]; - }), - ), + mapValues(data, (value, key) => { + if (value.length === 0) { + throw new Error(`No match found for demand ${key}`); + } + + return value[0]; + }), ); }, [setSelectedLLMProviders], @@ -63,15 +77,13 @@ export function AgentDemandsProvider({ children }: PropsWithChildren) { const setDefaultSelectedEmbeddingProviders = useCallback( (data: Record) => { setSelectedEmbeddingProviders( - Object.fromEntries( - Object.entries(data).map(([key, value]) => { - if (value.length === 0) { - throw new Error(`No match found for demand ${key}`); - } - - return [key, value[0]]; - }), - ), + mapValues(data, (value, key) => { + if (value.length === 0) { + throw new Error(`No match found for demand ${key}`); + } + + return value[0]; + }), ); }, [setSelectedEmbeddingProviders], @@ -101,10 +113,30 @@ export function AgentDemandsProvider({ children }: PropsWithChildren) { [setSelectedEmbeddingProviders], ); - const provideFormValues = useCallback((values: RunFormValues) => { - formFulfillmentsRef.current = { form_fulfillments: { initial_form: { values } } }; + const provideFormValues = useCallback(({ formId, values }: ProvideFormValuesParams) => { + formFulfillmentsRef.current = { + ...formFulfillmentsRef.current, + form_fulfillments: { + ...formFulfillmentsRef.current.form_fulfillments, + [formId]: { values }, + }, + }; }, []); + const onUpdateSettings = useCallback( + (values: SettingsFormValues) => { + setSelectedSettings(values); + + if (settingsFormDemanded) { + provideFormValues({ + formId: 'settings_form', + values, + }); + } + }, + [provideFormValues, settingsFormDemanded], + ); + const { data: connectorsData } = useListConnectors(); const getFulfillments = useCallback( @@ -128,12 +160,23 @@ export function AgentDemandsProvider({ children }: PropsWithChildren) { selectedEmbeddingProviders, providedSecrets, selectedSettings, + legacySettingsDemands, + settingsFormDemanded, formFulfillments: formFulfillmentsRef.current, oauthRedirectUri: oauthRedirectUri ?? null, connectors: connectorsData?.items ?? [], }); }, - [contextToken, selectedLLMProviders, selectedEmbeddingProviders, selectedSettings, demandedSecrets, connectorsData], + [ + contextToken, + selectedLLMProviders, + selectedEmbeddingProviders, + selectedSettings, + legacySettingsDemands, + settingsFormDemanded, + demandedSecrets, + connectorsData, + ], ); const value = useMemo( @@ -152,15 +195,14 @@ export function AgentDemandsProvider({ children }: PropsWithChildren) { selected: selectedEmbeddingProviders, select: selectEmbeddingProvider, }, - provideFormValues, - getFulfillments, + formDemands, + settingsForm, selectedSettings, - settingsDemands: agentClient?.demands.settingsDemands ?? null, - formDemands: agentClient?.demands.formDemands ?? null, + getFulfillments, + provideFormValues, onUpdateSettings, }), [ - agentClient, getFulfillments, isEmbeddingProvidersEnabled, isEmbeddingProvidersPending, @@ -170,6 +212,8 @@ export function AgentDemandsProvider({ children }: PropsWithChildren) { matchedLLMProviders, onUpdateSettings, provideFormValues, + formDemands, + settingsForm, selectEmbeddingProvider, selectLLMProvider, selectedEmbeddingProviders, 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 eea2a5bb4..5bde9e721 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,7 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ApprovalDecision, FormDemands, Fulfillments, SettingsDemands, SettingsValues } from 'agentstack-sdk'; +import type { + ApprovalDecision, + FormDemands, + Fulfillments, + SettingsFormRender, + SettingsFormValues, +} from 'agentstack-sdk'; import { createContext } from 'react'; import type { RunFormValues } from '#modules/form/types.ts'; @@ -26,15 +32,20 @@ export interface ModelProvidersContextValue { select: (key: string, value: string) => void; } +export type ProvideFormValuesParams = { + formId: 'initial_form' | 'settings_form'; + values: RunFormValues; +}; + interface AgentDemandsContextValue { llmProviders: ModelProvidersContextValue; embeddingProviders: ModelProvidersContextValue; - getFulfillments: (context: FulfillmentsContext) => Promise; - provideFormValues: (values: RunFormValues) => void; - onUpdateSettings: (settings: SettingsValues) => void; - selectedSettings: SettingsValues | undefined; - settingsDemands: SettingsDemands | null; formDemands: FormDemands | null; + settingsForm: SettingsFormRender | null; + selectedSettings: SettingsFormValues | undefined; + getFulfillments: (context: FulfillmentsContext) => Promise; + provideFormValues: ({ formId, values }: ProvideFormValuesParams) => void; + onUpdateSettings: (settings: SettingsFormValues) => void; } 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 376e581c2..9dd899e9c 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 @@ -10,10 +10,12 @@ import type { FormFulfillments, Fulfillments, MCPFulfillments, - SettingsValues, + SettingsDemands, + SettingsFormValues, } from 'agentstack-sdk'; import { ConnectorState, MCPTransportType } from 'agentstack-sdk'; +import { transformSettingsFormValuesToLegacySettingsValues } from '#modules/runs/settings/utils.ts'; import { BASE_URL } from '#utils/constants.ts'; interface BuildFulfillmentsParams { @@ -21,7 +23,9 @@ interface BuildFulfillmentsParams { selectedLLMProviders: Record; selectedEmbeddingProviders: Record; providedSecrets: Record; - selectedSettings: SettingsValues; + legacySettingsDemands: SettingsDemands | null; + settingsFormDemanded: boolean; + selectedSettings: SettingsFormValues; formFulfillments: FormFulfillments; oauthRedirectUri: string | null; connectors: Connector[]; @@ -32,26 +36,30 @@ export const buildFulfillments = ({ selectedLLMProviders, selectedEmbeddingProviders, selectedSettings, + legacySettingsDemands, + settingsFormDemanded, providedSecrets, formFulfillments, oauthRedirectUri, connectors, }: BuildFulfillmentsParams): Fulfillments => { - return { + const fulfillments: Fulfillments = { // @deprecated - token now passed via A2A client headers getContextToken: () => contextToken, - settings: async () => { - return { - values: selectedSettings, - }; - }, - form: async (demands) => { - if (demands.form_demands.initial_form && !formFulfillments.form_fulfillments['initial_form']) { + if (demands.form_demands.initial_form && !formFulfillments.form_fulfillments.initial_form) { throw new Error('Initial form has not been fulfilled despite being demanded.'); } + if (!demands.form_demands.settings_form && formFulfillments.form_fulfillments.settings_form) { + const form_fulfillments = { ...formFulfillments.form_fulfillments }; + + delete form_fulfillments.settings_form; + + return { form_fulfillments }; + } + return formFulfillments; }, @@ -155,4 +163,13 @@ export const buildFulfillments = ({ return oauthRedirectUri; }, }; + + if (legacySettingsDemands && !settingsFormDemanded) { + // @deprecated - use form extension with "settings_form" demand instead + fulfillments.settings = async () => ({ + values: transformSettingsFormValuesToLegacySettingsValues(selectedSettings), + }); + } + + return fulfillments; }; 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 875d7b83f..42435e6e8 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 @@ -331,7 +331,10 @@ function AgentRunProvider({ agent, children }: PropsWithChildren) { (form: UIMessageForm) => { checkPendingRun(); - provideFormValues(form.response); + provideFormValues({ + formId: 'initial_form', + values: form.response, + }); const message: UIUserMessage = { id: uuid(), diff --git a/apps/agentstack-ui/src/modules/runs/settings/RunSettings.tsx b/apps/agentstack-ui/src/modules/runs/settings/RunSettings.tsx index 348ca861a..cc352ea37 100644 --- a/apps/agentstack-ui/src/modules/runs/settings/RunSettings.tsx +++ b/apps/agentstack-ui/src/modules/runs/settings/RunSettings.tsx @@ -16,15 +16,15 @@ interface Props { } export function RunSettings({ dialog, iconOnly }: Props) { - const { settingsDemands } = useAgentDemands(); + const { settingsForm } = useAgentDemands(); - if (!settingsDemands?.fields.length) { + if (!settingsForm?.fields.length) { return null; } return ( - + ); } diff --git a/apps/agentstack-ui/src/modules/runs/settings/RunSettingsForm.tsx b/apps/agentstack-ui/src/modules/runs/settings/RunSettingsForm.tsx index 905bb7609..de703101d 100644 --- a/apps/agentstack-ui/src/modules/runs/settings/RunSettingsForm.tsx +++ b/apps/agentstack-ui/src/modules/runs/settings/RunSettingsForm.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SettingsDemands, SettingsValues } from 'agentstack-sdk'; +import type { SettingsFormRender, SettingsFormValues } from 'agentstack-sdk'; import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; @@ -14,18 +14,18 @@ import { SingleSelectSettingsField } from './SingleSelectSettingsField'; import { ToggleSettingsField } from './ToggleSettingsField'; interface Props { - settingsDemands: SettingsDemands; + settingsForm: SettingsFormRender; } -export function RunSettingsForm({ settingsDemands }: Props) { +export function RunSettingsForm({ settingsForm }: Props) { const { selectedSettings, onUpdateSettings } = useAgentDemands(); - const form = useForm({ + const form = useForm({ defaultValues: selectedSettings, }); useEffect(() => { - const subscription = form.watch((values: SettingsValues) => { + const subscription = form.watch((values: SettingsFormValues) => { onUpdateSettings(values); }); @@ -35,30 +35,18 @@ export function RunSettingsForm({ settingsDemands }: Props) { return (
- {settingsDemands.fields.map((group) => { - return match(group) - .with({ type: 'checkbox_group' }, ({ id, fields }) => ( -
- {fields.map((field) => ( - + {settingsForm.fields.map((group) => + match(group) + .with({ type: 'checkbox_group' }, (groupField) => ( +
+ {groupField.fields.map((field) => ( + ))}
)) - .with({ type: 'single_select' }, ({ id, label, options }) => ( - - )) - .exhaustive(); - })} + .with({ type: 'singleselect' }, (field) => ) + .exhaustive(), + )} ); diff --git a/apps/agentstack-ui/src/modules/runs/settings/SingleSelectSettingsField.tsx b/apps/agentstack-ui/src/modules/runs/settings/SingleSelectSettingsField.tsx index de8bfc1fb..fc3f8929f 100644 --- a/apps/agentstack-ui/src/modules/runs/settings/SingleSelectSettingsField.tsx +++ b/apps/agentstack-ui/src/modules/runs/settings/SingleSelectSettingsField.tsx @@ -3,30 +3,32 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SettingsSingleSelectFieldValue } from 'agentstack-sdk'; +import type { SingleSelectField, SingleSelectFieldValue } from 'agentstack-sdk'; import { useController } from 'react-hook-form'; import { RadioSelect } from '#components/RadioSelect/RadioSelect.tsx'; +import { getFieldName } from '#modules/form/utils.ts'; -export function SingleSelectSettingsField({ - field, -}: { - field: { id: string; label: string; options: { value: string; label: string }[] }; -}) { - const { id, label } = field; +interface Props { + field: SingleSelectField; +} + +export function SingleSelectSettingsField({ field }: Props) { + const { label } = field; + + const name = getFieldName(field); + const options = field.options.map(({ id, label }) => ({ value: id, label })); const { - field: { onChange, value }, - } = useController<{ [key: string]: SettingsSingleSelectFieldValue }, `${typeof id}.value`>({ - name: `${id}.value`, - }); + field: { value, onChange }, + } = useController, typeof name>({ name }); return ( ); diff --git a/apps/agentstack-ui/src/modules/runs/settings/ToggleSettingsField.tsx b/apps/agentstack-ui/src/modules/runs/settings/ToggleSettingsField.tsx index f50299111..c14ea35b9 100644 --- a/apps/agentstack-ui/src/modules/runs/settings/ToggleSettingsField.tsx +++ b/apps/agentstack-ui/src/modules/runs/settings/ToggleSettingsField.tsx @@ -4,26 +4,34 @@ */ import { Toggle } from '@carbon/react'; -import type { SettingsCheckboxFieldValue } from 'agentstack-sdk'; +import type { CheckboxField, CheckboxGroupField, CheckboxGroupFieldValue } from 'agentstack-sdk'; import { useController } from 'react-hook-form'; +import { getFieldName } from '#modules/form/utils.ts'; + import classes from './ToggleSettingsField.module.scss'; -export function ToggleSettingsField({ field }: { field: { label: string; id: string } }) { - const { id, label } = field; +interface Props { + field: CheckboxField; + groupField: CheckboxGroupField; +} + +export function ToggleSettingsField({ field, groupField }: Props) { + const { id, content } = field; + + const groupName = getFieldName(groupField); + const name = `${groupName}.${id}` as const; const { - field: { onChange, value }, - } = useController<{ [key: string]: SettingsCheckboxFieldValue }>({ - name: `${id}.value`, - }); + field: { value, onChange }, + } = useController, typeof name>({ name }); return ( ((valuesAcc, field) => { +export function getInitialSettingsFormValues(settingsForm: SettingsFormRender | null) { + const fields = settingsForm?.fields ?? []; + + const defaults = fields.reduce((valuesAcc, field) => { valuesAcc[field.id] = match(field) .with({ type: 'checkbox_group' }, ({ fields }) => { - const values = fields.reduce>((acc, field) => { - acc[field.id] = { - value: field.default_value, - }; + const values = fields.reduce>((acc, field) => { + acc[field.id] = field.default_value; return acc; }, {}); - return { type: 'checkbox_group', values } as const; + return { + type: 'checkbox_group', + value: values, + } satisfies CheckboxGroupFieldValue; }) - .with({ type: 'single_select' }, ({ default_value }) => { - return { type: 'single_select', value: default_value } as const; + .with({ type: 'singleselect' }, ({ default_value }) => { + return { + type: 'singleselect', + value: default_value, + } satisfies SingleSelectFieldValue; }) .exhaustive(); @@ -30,3 +48,75 @@ export function getSettingsDemandsDefaultValues(settingsDemands: SettingsDemands return defaults; } + +export function transformLegacySettingsDemandsToSettingsForm( + settingsDemands: SettingsDemands | null, +): SettingsFormRender | null { + if (!settingsDemands) { + return null; + } + + const settingsForm = { + fields: settingsDemands.fields.map((field) => + match(field) + .with( + { type: 'checkbox_group' }, + ({ id, fields }) => + ({ + id, + label: '', + type: 'checkbox_group', + fields: fields.map(({ id, label, default_value }) => ({ + id, + label, + type: 'checkbox', + content: label, + default_value, + })), + }) satisfies CheckboxGroupField, + ) + .with( + { type: 'single_select' }, + ({ id, label, options, default_value }) => + ({ + id, + label, + type: 'singleselect', + options: options.map(({ value, label }) => ({ + id: value, + label, + })), + default_value, + }) satisfies SingleSelectField, + ) + .exhaustive(), + ), + }; + + return settingsForm; +} + +export function transformSettingsFormValuesToLegacySettingsValues(settingsValues: SettingsFormValues): SettingsValues { + const legacySettingsValues = mapValues(settingsValues, (value) => + match(value) + .with( + { type: 'checkbox_group' }, + ({ value: groupValue }) => + ({ + type: 'checkbox_group', + values: mapValues(groupValue ?? {}, (value) => ({ value: value ?? false })), + }) satisfies SettingsCheckboxGroupFieldValue, + ) + .with( + { type: 'singleselect' }, + ({ value }) => + ({ + type: 'single_select', + value: value ?? '', + }) satisfies SettingsSingleSelectFieldValue, + ) + .exhaustive(), + ); + + return legacySettingsValues; +} diff --git a/apps/agentstack-ui/src/utils/helpers.ts b/apps/agentstack-ui/src/utils/helpers.ts index 89367408b..23d474872 100644 --- a/apps/agentstack-ui/src/utils/helpers.ts +++ b/apps/agentstack-ui/src/utils/helpers.ts @@ -45,12 +45,6 @@ export function isImageMimeType(mimeType: string | undefined): boolean { return Boolean(mimeType?.toLowerCase().startsWith('image/')); } -export function objectFromEntries>( - entries: T, -): { [K in T[number] as K[0]]: K[1] } { - return Object.fromEntries(entries) as { [K in T[number] as K[0]]: K[1] }; -} - export function ensureBase64Uri(value: string, contentType?: string | null): string { const pattern = /^data:[^;]+;base64,/; From 8f868f23961b51fd2c95ded410e8276a390abd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Bula=CC=81nek?= Date: Tue, 3 Feb 2026 09:48:17 +0100 Subject: [PATCH 3/4] docs(sdk): update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Petr Bulánek --- .../custom-ui/agent-requirements.mdx | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/development/custom-ui/agent-requirements.mdx b/docs/development/custom-ui/agent-requirements.mdx index 268e8d8ab..7fbd2050c 100644 --- a/docs/development/custom-ui/agent-requirements.mdx +++ b/docs/development/custom-ui/agent-requirements.mdx @@ -138,9 +138,9 @@ const metadata = await resolveMetadata({ }); ``` -### Settings +### Settings (deprecated) -Provides runtime configuration values that match the requested fields. +Deprecated. Planned for removal in the next release. Use [Settings Form](#settings-form) instead. This extension lives under `ui/settings` in the SDK, but it is treated as a service extension because it carries demands and fulfillments. @@ -171,6 +171,15 @@ const metadata = await resolveMetadata({ }); ``` +#### Migration guide + +- Agent cards: replace the `ui/settings` extension with the `services/form` extension and move settings fields to `form_demands.settings_form`. +- Single select: change type `single_select` to `singleselect`, and change `options` items from `{ value }` to `{ id }`. +- Checkbox group: `checkbox_group` now requires a non empty `label`. +- Checkbox items: `checkbox` now requires `content` for the visible text. Keep `label` populated too and map the legacy checkbox label to `content` when migrating. +- Fulfillments: change checkbox group values from `{ values: { [id]: { value } } }` to `{ value: { [id]: boolean | null } }`. + + ### Form Provides form responses when the agent requests structured input. @@ -191,6 +200,62 @@ const metadata = await resolveMetadata({ }); ``` +#### Settings Form + +Settings UI is delivered through the Form service extension. Agents request settings with `form_demands.settings_form`, and clients respond with `form_fulfillments.settings_form`. + +```typescript +import { handleAgentCard } from "agentstack-sdk"; + +const { resolveMetadata } = handleAgentCard(agentCard); + +const metadata = await resolveMetadata({ + form: async (demands) => { + const settingsForm = demands.form_demands.settings_form; + + if (!settingsForm) { + return { form_fulfillments: {} }; + } + + return { + form_fulfillments: { + settings_form: { + values: Object.fromEntries( + settingsForm.fields.map((field) => { + if (field.type === "singleselect") { + return [ + field.id, + { + type: "singleselect", + value: field.default_value + } + ]; + } + + if (field.type === "checkbox_group") { + return [ + field.id, + { + type: "checkbox_group", + value: Object.fromEntries( + field.fields.map((checkbox) => [checkbox.id, checkbox.default_value]), + ), + }, + ]; + } + + throw new Error("Unsupported settings field type"); + }), + ), + }, + }, + }; + }, +}); +``` + +Prefer `settings_form` when both `settings_form` and legacy settings demands are present. + ### Platform API Adds context token metadata so the agent can call platform services. This is typically used only when you cannot pass the token through A2A client headers. From fcdccc1693a2df9318c47b1444f54daccfc215c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Bula=CC=81nek?= Date: Thu, 12 Feb 2026 10:27:18 +0100 Subject: [PATCH 4/4] refactor: remove unnecessary if check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Petr Bulánek --- .../runs/contexts/agent-demands/build-fulfillments.ts | 8 -------- 1 file changed, 8 deletions(-) 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 9dd899e9c..e642182e9 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 @@ -52,14 +52,6 @@ export const buildFulfillments = ({ throw new Error('Initial form has not been fulfilled despite being demanded.'); } - if (!demands.form_demands.settings_form && formFulfillments.form_fulfillments.settings_form) { - const form_fulfillments = { ...formFulfillments.form_fulfillments }; - - delete form_fulfillments.settings_form; - - return { form_fulfillments }; - } - return formFulfillments; },