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..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 @@ -53,6 +53,11 @@ export const checkboxFieldSchema = baseFieldSchema.extend({ default_value: z.boolean().nullish(), }); +export const checkboxGroupFieldSchema = baseFieldSchema.extend({ + type: z.literal('checkbox_group'), + fields: z.array(checkboxFieldSchema), +}); + export const formFieldSchema = z.discriminatedUnion('type', [ textFieldSchema, dateFieldSchema, @@ -60,6 +65,7 @@ export const formFieldSchema = z.discriminatedUnion('type', [ singleSelectFieldSchema, multiSelectFieldSchema, checkboxFieldSchema, + checkboxGroupFieldSchema, ]); export const textFieldValueSchema = z.object({ @@ -100,6 +106,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()).nullish(), +}); + export const formFieldValueSchema = z.discriminatedUnion('type', [ textFieldValueSchema, dateFieldValueSchema, @@ -107,6 +118,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-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/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..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,8 +23,24 @@ export function getDefaultValues(fields: FormField[]) { { type: 'singleselect' }, { type: 'multiselect' }, { type: 'checkbox' }, - ({ 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/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; 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..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 @@ -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,23 +36,19 @@ 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.'); } @@ -155,4 +155,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,/; 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.