- {sections.map((section) => {
- const active = current === section.id;
- const Icon = section.icon ?? FileText;
- return (
-
goToSection(section.id)}
- className={clsx(
- 'flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
- active ? 'bg-accent-soft font-semibold text-accent' : 'text-text-2 hover:bg-surface-2 hover:text-text-1',
- )}>
-
- {section.label}
-
- );
- })}
+
+
+
+ {sections.map((section) => {
+ const active = current === section.id;
+ const Icon = section.icon ?? FileText;
+ return (
+
goToSection(section.id)}
+ className={clsx(
+ 'flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
+ active ? 'bg-accent-soft font-semibold text-accent' : 'text-text-2 hover:bg-surface-2 hover:text-text-1',
+ )}>
+
+ {section.label}
+ {aiChangedSections?.has(section.id) ? : null}
+
+ );
+ })}
+
+
+
+
+ Jump to section
+ { if (event.target.value) goToSection(event.target.value); }}
+ className="mt-1 block w-full rounded-xl border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1">
+ Choose a section
+ {sections.map((section) => {section.label} )}
+
+
-
-
-
- Jump to section
- { if (event.target.value) goToSection(event.target.value); }}
- className="mt-1 block w-full rounded-xl border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1">
- Choose a section
- {sections.map((section) => {section.label} )}
-
-
-
-
-
- {error ? (
-
-
- {error}
-
- ) : null}
-
- {readOnly ? 'Configuration details' : 'Configuration'}
- {sections.map((section) => section.id === 'advanced' ? (
-
-
-
-
- {section.label}
- {section.description ? {section.description} : null}
-
-
-
- {section.content}
-
- ) : (
-
-
-
-
-
{section.label}
- {section.description ?
{section.description}
: null}
-
-
- {section.content}
+
+
+ {locked && lockBanner ? lockBanner : null}
+ {error ? (
+
+
+ {error}
- ))}
-
+ ) : null}
+
+ {readOnly ? 'Configuration details' : 'Configuration'}
+ {sections.map((section) => section.id === 'advanced' ? (
+
+
+
+
+
+ {section.label}
+ {aiChangedSections?.has(section.id) ? : null}
+
+ {section.description ? {section.description} : null}
+
+
+
+ {section.content}
+
+ ) : (
+
+
+
+
+
+
{section.label}
+ {aiChangedSections?.has(section.id) ?
: null}
+
+ {section.description ?
{section.description}
: null}
+
+
+ {section.content}
+
+ ))}
+
+
-
- {blocker.state === 'blocked' ? (
-
blocker.reset()}
- onDiscard={() => { onDiscard(); blocker.proceed(); }} />
- ) : null}
-
+ {blocker.state === 'blocked' ? (
+ blocker.reset()}
+ onDiscard={() => { onDiscard(); blocker.proceed(); }} />
+ ) : null}
+
+ {sidePanel && sidePanelOpen ? sidePanel : null}
+
);
}
diff --git a/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx b/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx
index 81b3c2ce3..2b4d18636 100644
--- a/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx
+++ b/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx
@@ -1,8 +1,9 @@
// AgentActionPicker.tsx
import { useState, type Dispatch, type SetStateAction } from 'react';
-import { Plus, RefreshCw, Trash2 } from 'lucide-react';
+import { PencilLine, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { actionTarget, referenceKey, type AgentTargetCatalog } from '../../lib/agentDelegation';
+import { isPendingActionReference, type PendingAgentAction } from '../../lib/agentEditorAssist';
import type { ActionConfiguration, AgentConfiguration, AgentEditorOptions } from '../../lib/workspaceAuthoring';
import {
AGENT_ACTION_CAPABILITIES, agentActionCapabilities, agentActionLabel, agentActionUnavailableReason,
@@ -13,9 +14,14 @@ import { EditorGroup, EditorPanel } from '../workspace/EditorLayout';
import { Pill, SectionSearch } from '../workspace/primitives';
import { AgentNotice } from './AgentFields';
+function pendingActionLabel(action: ActionConfiguration): string {
+ return (typeof action.displayName === 'string' && action.displayName.trim()) || action.name || 'New action';
+}
+
export function AgentActionPicker({
draft, setDraft, actions, targets, loading, error, targetError, ownerId, builtinActions,
canCreateActions, onRefresh, onNewAction, readOnly, scopeKind = 'personal',
+ pendingActions = [], pendingIssues = {}, onFinishPendingAction,
}: {
draft: AgentConfiguration;
setDraft: Dispatch
>;
@@ -32,13 +38,23 @@ export function AgentActionPicker({
readOnly: boolean;
/** Which kind of agent is being edited. A global agent's actions are all the organisation's own. */
scopeKind?: 'personal' | 'group' | 'global';
+ /** Actions Ask AI drafted for this agent; they are created when the agent is saved. */
+ pendingActions?: readonly PendingAgentAction[];
+ /** What each drafted action still needs before it can be created, by reference. */
+ pendingIssues?: Readonly>;
+ /** Opens a drafted action in the action editor to finish it, such as to add credentials. */
+ onFinishPendingAction?: (reference: string) => void;
}) {
const globalScope = scopeKind === 'global';
const [query, setQuery] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [selectedOnly, setSelectedOnly] = useState(false);
const [confirmDetach, setConfirmDetach] = useState(false);
- const unresolved = draft.actions_to_load.filter((reference) => !resolveAgentAction(reference, actions));
+ const pendingLabel = (reference: string) => {
+ const pending = pendingActions.find((item) => item.reference === reference);
+ return pending ? pendingActionLabel(pending.action) : null;
+ };
+ const unresolved = draft.actions_to_load.filter((reference) => !resolveAgentAction(reference, actions) && !isPendingActionReference(reference));
const types = [...new Set(actions.map((action) => action.type))].sort();
const visible = actions.filter((action) =>
`${agentActionLabel(action)} ${action.name} ${action.description} ${action.type} ${action.type === 'agent' ? 'Call agent' : ''}`.toLowerCase().includes(query.trim().toLowerCase()) &&
@@ -56,7 +72,7 @@ export function AgentActionPicker({
{draft.actions_to_load.map((reference) => {
const action = resolveAgentAction(reference, actions);
- return {action ? agentActionLabel(action) : 'Unavailable action'} ;
+ return {action ? agentActionLabel(action) : pendingLabel(reference) ?? 'Unavailable action'} ;
})}
{!readOnly ? confirmDetach ? (
@@ -89,6 +105,42 @@ export function AgentActionPicker({
{error ? {error} Existing references have not been removed. : null}
{targetError ? {targetError} Call agent targets could not be checked; existing assignments are preserved. : null}
{loading ? Loading available actions…
: null}
+ {pendingActions.length ? (
+
+
+ {pendingActions.map(({ reference, action }) => {
+ const label = pendingActionLabel(action);
+ const issues = pendingIssues[reference] ?? [];
+ return (
+
+
+
+
+ {label}{action.type} New
+
+ {action.description ? {action.description} : null}
+ {issues.length ? Needs attention before saving: {issues.join(' ')} : null}
+
+ {!readOnly ? (
+
+ {onFinishPendingAction && canCreateActions ? (
+ onFinishPendingAction(reference)}> Finish in action editor
+ ) : null}
+ setDraft((current) => ({ ...current, actions_to_load: current.actions_to_load.filter((item) => item !== reference) }))}>
+ Remove
+
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ ) : null}
diff --git a/application/v2_ui/src/components/workspaceAgents/AgentAdvancedFields.tsx b/application/v2_ui/src/components/workspaceAgents/AgentAdvancedFields.tsx
index af0350eb4..b19108ae3 100644
--- a/application/v2_ui/src/components/workspaceAgents/AgentAdvancedFields.tsx
+++ b/application/v2_ui/src/components/workspaceAgents/AgentAdvancedFields.tsx
@@ -1,13 +1,12 @@
// AgentAdvancedFields.tsx
import type { Dispatch, SetStateAction } from 'react';
-import { getModelSupportedLevels } from '../../lib/reasoning';
import type { ModelCatalogEntry } from '../../lib/models';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import type { AgentConfiguration, AgentEditorOptions, AuthoringResource } from '../../lib/workspaceAuthoring';
import {
- AGENT_INPUT_CLASS, agentModelChoices, agentStoredArrayEditError, agentText, clearAgentDraftFields, parseAgentSettings,
- secretValueAt, selectedAgentModel, setAgentSecret,
+ AGENT_INPUT_CLASS, agentReasoningLevels, agentStoredArrayEditError, agentText, clearAgentDraftFields, parseAgentSettings,
+ secretValueAt, setAgentSecret,
} from '../../lib/workspaceAgentAuthoring';
import { GlassButton } from '../ui/primitives';
import { EditorGroup } from '../workspace/EditorLayout';
@@ -35,13 +34,8 @@ export function AgentAdvancedFields({
original: AuthoringResource
| null;
options: AgentEditorOptions;
}) {
- const selectedModel = selectedAgentModel(draft, agentModelChoices(options));
const models = useBootstrapStore((state) => state.data?.catalogs?.models) as ModelCatalogEntry[] | undefined;
- const catalogModel = models?.find((model) => draft.model_endpoint_id
- ? model.endpoint_id === draft.model_endpoint_id && model.model_id === draft.model_id
- : model.model_name === selectedModel?.modelName &&
- model.deployment_name === (draft.azure_openai_gpt_deployment || draft.azure_agent_apim_gpt_deployment));
- const levels = getModelSupportedLevels(catalogModel?.reasoning_capabilities);
+ const levels = agentReasoningLevels(draft, options, models);
const rawSettings = typeof draft._editor_settings_text === 'string' ? draft._editor_settings_text : JSON.stringify(draft.other_settings, null, 2);
const error = agentAdvancedError(draft, original);
return (
diff --git a/application/v2_ui/src/lib/actionEditorAssist.ts b/application/v2_ui/src/lib/actionEditorAssist.ts
new file mode 100644
index 000000000..326ae3431
--- /dev/null
+++ b/application/v2_ui/src/lib/actionEditorAssist.ts
@@ -0,0 +1,533 @@
+// actionEditorAssist.ts
+// How the action editor describes its draft to Ask AI, and how it applies Ask AI's changes.
+//
+// The view describes the fields a person can set in the editor for each action type. The action
+// type is a variant: choosing another type brings in that type's fields. Credentials, secret
+// values, identities and raw JSON are never described, so Ask AI can't read or set them. Applying
+// a patch goes through the editor's own helpers (type switch, field mirrors, authentication
+// method), so a change from Ask AI is the same change the person could have made by hand.
+
+import type {
+ EditorAssistField, EditorAssistFieldKind, EditorAssistOption, EditorAssistSection, EditorAssistValues, EditorAssistView,
+} from './editorAssist';
+import {
+ EDITOR_SECRET_MASK, isRecord, pointerPart, type ActionConfiguration, type ActionTypeDefinition, type EditorSchema,
+} from './workspaceAuthoring';
+import {
+ actionAuthMethod, actionAuthModes, actionText, actionTypeLabel, actionValueAt, changeActionAuth,
+ changeActionDisplayName, changeActionField, changeActionType, createActionDraft, displayedActionValue, resolveActionSchema,
+} from './workspaceActionLogic';
+import { nativeActionDefinition } from './workspaceActionRegistry';
+import type { ActionFieldDescriptor } from './workspaceActionTypes';
+
+/** The editor's sections, in the order the editor shows them. */
+export const ACTION_ASSIST_SECTIONS: readonly EditorAssistSection[] = [
+ { id: 'identity', label: 'Identity and type' },
+ { id: 'configuration', label: 'Configuration' },
+ { id: 'authentication', label: 'Authentication' },
+ { id: 'advanced', label: 'Advanced' },
+];
+
+/** Paths the server accepts for an action; anything else would refuse the whole request. */
+const ACTION_PATH = /^\/(displayName|description|type|endpoint|deployment|api_version|auth\/type|additionalFields\/[A-Za-z0-9_]{1,80}|metadata\/[A-Za-z0-9_]{1,80})$/;
+const SECRET_KEY = /secret|password|credential|connection|private|sas_url|cookie|bearer|token|api_key|^key$/i;
+const DESCRIPTION_LIMIT = 2000;
+const DISPLAY_NAME_LIMIT = 100;
+const MAX_SCHEMA_OPTIONS = 200;
+
+export interface ActionAssistContext {
+ /** The action types this workspace may use. */
+ readonly catalogue: readonly ActionTypeDefinition[];
+ readonly isNew: boolean;
+ /** Paths the saved action stores as secrets; never described. */
+ readonly secretPaths?: readonly string[];
+}
+
+/** How a described field is read and written. */
+type FieldSource =
+ | { readonly kind: 'native'; readonly descriptor: ActionFieldDescriptor }
+ | { readonly kind: 'capabilities'; readonly path: string }
+ | { readonly kind: 'auth' }
+ | { readonly kind: 'mcp'; readonly key: string; readonly fallback: unknown }
+ | { readonly kind: 'endpoint' }
+ | { readonly kind: 'schema'; readonly schema: EditorSchema };
+
+interface DescribedField {
+ readonly field: EditorAssistField;
+ readonly source: FieldSource;
+}
+
+function definitionFor(type: string, context: ActionAssistContext): ActionTypeDefinition | undefined {
+ return context.catalogue.find((definition) => definition.type === type);
+}
+
+function fallbackDefinition(draft: ActionConfiguration): ActionTypeDefinition {
+ return {
+ type: draft.type, display: actionTypeLabel(draft.type), description: '',
+ allowed_auth_types: draft.auth.type ? [draft.auth.type] : [],
+ additional_fields_schema: {}, metadata_schema: {},
+ };
+}
+
+function isSecret(draft: ActionConfiguration, path: string, context: ActionAssistContext): boolean {
+ if (context.secretPaths?.includes(path) || actionValueAt(draft, path) === EDITOR_SECRET_MASK) return true;
+ const key = path.split('/').at(-1) ?? '';
+ return key.endsWith('__Secret') || SECRET_KEY.test(key);
+}
+
+function nativeKind(descriptor: ActionFieldDescriptor): EditorAssistFieldKind | null {
+ switch (descriptor.kind ?? 'text') {
+ case 'text': return 'text';
+ case 'textarea': return 'textarea';
+ case 'number': return 'number';
+ case 'boolean': return 'boolean';
+ case 'select': return 'select';
+ case 'lines': return 'lines';
+ default: return null;
+ }
+}
+
+function schemaField(key: string, schema: EditorSchema, root: EditorSchema, required: boolean): Omit | null {
+ const resolved = resolveActionSchema(schema, root);
+ const types = (Array.isArray(resolved.type) ? resolved.type : [resolved.type]).filter((type) => type && type !== 'null');
+ const label = resolved.title || key.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
+ const help = resolved.description;
+ const values = resolved.enum;
+ if (Object.hasOwn(resolved, 'const')) return null;
+ if (values) {
+ if (!values.every((item) => typeof item === 'string') || values.length > MAX_SCHEMA_OPTIONS) return null;
+ // A blank enum entry means "not set", which Ask AI expresses as null.
+ const options = (values as string[]).filter(Boolean).map((value) => ({ value }));
+ return options.length ? { label, help, required, kind: 'select', options } : null;
+ }
+ const type = types[0] ?? (resolved.properties ? 'object' : 'string');
+ if (type === 'string') return { label, help, required, kind: 'text', max_length: resolved.maxLength };
+ if (type === 'number' || type === 'integer') {
+ return { label, help, required, kind: 'number', min: resolved.minimum, max: resolved.maximum, integer: type === 'integer' };
+ }
+ if (type === 'boolean') return { label, help, required, kind: 'boolean' };
+ if (type === 'array') {
+ const items = resolveActionSchema(resolved.items ?? {}, root);
+ const itemTypes = Array.isArray(items.type) ? items.type : [items.type];
+ if (itemTypes.includes('string') && !items.enum) return { label, help, required, kind: 'lines', max_items: resolved.maxItems };
+ }
+ return null;
+}
+
+/** The covered paths the editor renders itself, so schema properties don't repeat them. */
+function coveredPaths(draft: ActionConfiguration): string[] {
+ const native = nativeActionDefinition(draft.type);
+ const sql = ['sql_query', 'sql_schema'].includes(draft.type);
+ return [
+ ...native.fields.map(({ path }) => path),
+ '/additionalFields/identity_auth_type',
+ ...(sql ? ['/additionalFields/auth_type', '/additionalFields/username', '/additionalFields/password', '/additionalFields/identity_uses_connection_string'] : []),
+ ...(['databricks', 'databricks_table', 'snowflake', 'tableau', 'yamcs'].includes(draft.type) ? ['/additionalFields/auth_method'] : []),
+ ...(['databricks', 'databricks_table'].includes(draft.type) ? ['/additionalFields/workspace_url'] : []),
+ ...(['tableau', 'yamcs'].includes(draft.type) ? ['/additionalFields/server_url'] : []),
+ ...(draft.type === 'tableau' ? ['/additionalFields/pat_name'] : []),
+ ...(draft.type === 'rocksdb' ? ['/additionalFields/auth_scheme', '/additionalFields/api_key_header', '/additionalFields/base_url'] : []),
+ ...(native.capabilities ? [native.capabilities.path] : []),
+ ];
+}
+
+const MCP_FIELDS: readonly { key: string; field: Omit; fallback: unknown }[] = [
+ { key: 'load_tools', fallback: true, field: { label: 'Load tools', kind: 'boolean', help: 'Expose tools from this MCP server to agents using this action.' } },
+ { key: 'load_prompts', fallback: false, field: { label: 'Load prompts', kind: 'boolean', help: 'Load MCP server prompts in addition to any enabled tools.' } },
+ { key: 'validate_tool_arguments', fallback: false, field: { label: 'Validate tool arguments', kind: 'boolean', help: 'Check arguments against cached tool input schemas before invocation.' } },
+ {
+ key: 'tool_result_policy', fallback: 'truncate', field: {
+ label: 'Large-result policy', kind: 'select', options: [
+ { value: 'truncate', label: 'Truncate results above the configured size limit' },
+ { value: 'error_on_limit', label: 'Report an error instead of truncating' },
+ ],
+ },
+ },
+ {
+ key: 'allowed_tool_names', fallback: [], field: {
+ label: 'Allowed tool names', kind: 'lines', max_items: 500,
+ help: 'Original MCP tool names. An empty allowlist allows all server tools. Only add names the person gave or that discovery found.',
+ },
+ },
+];
+
+/** Every field Ask AI may read or set for the draft's current type. */
+function describeType(draft: ActionConfiguration, definition: ActionTypeDefinition, context: ActionAssistContext): DescribedField[] {
+ const described: DescribedField[] = [];
+ const seen = new Set(['/displayName', '/description', '/type']);
+ const add = (field: EditorAssistField, source: FieldSource) => {
+ if (seen.has(field.path) || !ACTION_PATH.test(field.path)) return;
+ seen.add(field.path);
+ described.push({ field, source });
+ };
+ if (!draft.type || draft.type === 'agent') return described;
+ if (draft.type === 'openapi') {
+ add({
+ path: '/endpoint', label: 'Base URL', section: 'configuration', kind: 'text',
+ help: 'The API base URL. The OpenAPI specification itself is uploaded or fetched by the person in the editor.',
+ }, { kind: 'endpoint' });
+ return described;
+ }
+ if (draft.type === 'mcp') {
+ add({
+ path: '/endpoint', label: 'MCP server endpoint', section: 'configuration', kind: 'text', required: true,
+ help: 'A remote MCP server URL the person named, such as https://example.com/mcp.',
+ }, { kind: 'endpoint' });
+ for (const item of MCP_FIELDS) {
+ add({ ...item.field, path: `/additionalFields/${item.key}`, section: 'configuration' },
+ { kind: 'mcp', key: item.key, fallback: item.fallback });
+ }
+ return described;
+ }
+ const native = nativeActionDefinition(draft.type);
+ for (const descriptor of native.fields) {
+ if (descriptor.visible && !descriptor.visible(draft)) continue;
+ const kind = nativeKind(descriptor);
+ if (!kind || isSecret(draft, descriptor.path, context)) continue;
+ add({
+ path: descriptor.path, label: descriptor.label, section: 'configuration', kind,
+ help: descriptor.help, required: descriptor.required,
+ min: descriptor.min, max: descriptor.max, integer: descriptor.step === 1 || undefined,
+ options: kind === 'select' ? descriptor.options?.filter(({ value }) => value).map(({ value, label }) => ({ value, label })) : undefined,
+ read_only: descriptor.readOnly || undefined,
+ }, { kind: 'native', descriptor });
+ }
+ if (native.capabilities) {
+ add({
+ path: native.capabilities.path, label: 'Allowed capabilities', section: 'configuration', kind: 'choices',
+ max_items: native.capabilities.options.length,
+ options: native.capabilities.options.map(({ key, label, description }) => ({ value: key, label, description })),
+ help: 'What an agent can do through this action. Every capability not chosen is turned off.',
+ }, { kind: 'capabilities', path: native.capabilities.path });
+ }
+ const schema = definition.additional_fields_schema;
+ const resolved = resolveActionSchema(schema, schema);
+ const covered = coveredPaths(draft);
+ for (const [key, property] of Object.entries(resolved.properties ?? {})) {
+ const path = `/additionalFields/${pointerPart(key)}`;
+ if (covered.some((known) => path === known || path.startsWith(`${known}/`)) || isSecret(draft, path, context)) continue;
+ const field = schemaField(key, property, schema, resolved.required?.includes(key) === true);
+ if (field) add({ ...field, path, section: 'configuration' }, { kind: 'schema', schema: property });
+ }
+ const modes = actionAuthModes(draft.type, definition.allowed_auth_types);
+ if (modes.length > 1) {
+ add({
+ path: '/auth/type', label: 'Authentication method', section: 'authentication', kind: 'select',
+ options: modes.map(({ value, label }) => ({ value, label })),
+ read_only: Boolean(draft.identity_id) || undefined,
+ help: draft.identity_id
+ ? 'A reusable identity signs this action in, so the method follows the identity.'
+ : 'How the action signs in. The person enters any credentials the method needs.',
+ }, { kind: 'auth' });
+ }
+ return described;
+}
+
+function stringList(value: unknown): string[] {
+ if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string');
+ if (typeof value === 'string') return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
+ return [];
+}
+
+function capabilityValues(draft: ActionConfiguration, path: string): string[] {
+ const native = nativeActionDefinition(draft.type);
+ const raw = actionValueAt(draft, path);
+ const stored = isRecord(raw) ? raw : {};
+ return (native.capabilities?.options ?? []).filter((capability) => {
+ if (typeof stored[capability.key] === 'boolean') return stored[capability.key] === true;
+ // Older SimpleChat actions stored one upload switch for every document format.
+ if (draft.type === 'simplechat' && ['upload_word_document', 'upload_powerpoint_document'].includes(capability.key) &&
+ typeof stored.upload_markdown_document === 'boolean') return stored.upload_markdown_document === true;
+ return capability.defaultEnabled !== false;
+ }).map(({ key }) => key);
+}
+
+function readValue(draft: ActionConfiguration, item: DescribedField): unknown {
+ const { field, source } = item;
+ switch (source.kind) {
+ case 'capabilities':
+ return capabilityValues(draft, source.path);
+ case 'auth':
+ return actionAuthMethod(draft) || null;
+ case 'mcp': {
+ const value = draft.additionalFields[source.key];
+ if (field.kind === 'lines') return stringList(value);
+ if (field.kind === 'boolean') return typeof value === 'boolean' ? value : source.fallback;
+ return actionText(value) || source.fallback;
+ }
+ case 'endpoint':
+ return actionText(draft.endpoint);
+ default:
+ break;
+ }
+ const value = displayedActionValue(draft, field.path);
+ switch (field.kind) {
+ case 'boolean': {
+ if (typeof value === 'boolean') return value;
+ const defaults = actionValueAt(nativeActionDefinition(draft.type).defaults, field.path);
+ if (source.kind === 'schema') return resolveActionSchema(source.schema).default === true;
+ return defaults === true;
+ }
+ case 'number':
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
+ case 'select':
+ return value === undefined || value === null || value === '' ? null : String(value);
+ case 'lines':
+ return stringList(value);
+ default:
+ return typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value);
+ }
+}
+
+function typeFields(draft: ActionConfiguration, context: ActionAssistContext): DescribedField[] {
+ if (!draft.type) return [];
+ return describeType(draft, definitionFor(draft.type, context) ?? fallbackDefinition(draft), context);
+}
+
+/** Every value the assistant may read, keyed by path. */
+export function actionAssistValues(draft: ActionConfiguration, context: ActionAssistContext): EditorAssistValues {
+ const values: Record = {
+ '/displayName': draft.displayName ?? draft.name ?? '',
+ '/description': draft.description ?? '',
+ '/type': draft.type || null,
+ };
+ for (const item of typeFields(draft, context)) values[item.field.path] = readValue(draft, item);
+ return values;
+}
+
+/** The draft as Ask AI sees it. */
+export function buildActionAssistView(draft: ActionConfiguration, context: ActionAssistContext): EditorAssistView {
+ const types = [...context.catalogue];
+ const typeOptions = types.map((definition) => ({
+ value: definition.type, label: definition.display,
+ description: definition.description ? definition.description.slice(0, 900) : undefined,
+ }));
+ if (draft.type && !definitionFor(draft.type, context)) {
+ typeOptions.push({ value: draft.type, label: `${actionTypeLabel(draft.type)} (existing)`, description: undefined });
+ }
+ const fields: EditorAssistField[] = [
+ {
+ path: '/displayName', label: 'Action name', section: 'identity', kind: 'text', required: true,
+ max_length: DISPLAY_NAME_LIMIT, help: 'The name agents and people see for this action.',
+ },
+ {
+ path: '/description', label: 'Description', section: 'identity', kind: 'textarea',
+ max_length: DESCRIPTION_LIMIT, help: 'What this action does and when an agent should use it.',
+ },
+ {
+ path: '/type', label: 'Action type', section: 'identity', kind: 'select', required: true, options: typeOptions,
+ help: 'The kind of connector. Changing it switches configuration and authentication to that type; the previous type is kept in the draft if the person switches back.',
+ },
+ ];
+ const variants: Record = {};
+ for (const definition of types) {
+ // Each type is described as it would look after switching to it, so visibility rules hold.
+ const simulated = definition.type === draft.type ? draft : changeActionType(draft, definition);
+ variants[definition.type] = describeType(simulated, definition, context).map(({ field }) => field);
+ }
+ if (draft.type && !Object.hasOwn(variants, draft.type)) {
+ variants[draft.type] = typeFields(draft, context).map(({ field }) => field);
+ }
+ const notes = [
+ 'Keys, passwords, tokens, connection strings and reusable identities are never shared with Ask AI. The person enters them in Authentication.',
+ 'Connection tests, tool discovery and OpenAPI specification uploads are commands the person runs in the editor.',
+ ];
+ if (!types.length) notes.push('No action types are available to this workspace, so the type cannot change.');
+ if (draft.type && !definitionFor(draft.type, context)) {
+ notes.push("This action's type is no longer offered to this workspace. Choose a permitted type before saving.");
+ }
+ if (draft.type === 'agent') notes.push('A Call agent action picks its target agent in the editor; only its name and description can change here.');
+ return {
+ sections: ACTION_ASSIST_SECTIONS,
+ fields,
+ values: actionAssistValues(draft, context),
+ variant: { path: '/type', fields: variants },
+ notes,
+ };
+}
+
+function writeValue(draft: ActionConfiguration, item: DescribedField, value: unknown): ActionConfiguration | null {
+ const { field, source } = item;
+ if (field.read_only) return null;
+ switch (source.kind) {
+ case 'capabilities': {
+ if (!Array.isArray(value)) return null;
+ const chosen = new Set(stringList(value));
+ const options = nativeActionDefinition(draft.type).capabilities?.options ?? [];
+ if ([...chosen].some((key) => !options.some((option) => option.key === key))) return null;
+ const raw = actionValueAt(draft, source.path);
+ const next = { ...(isRecord(raw) ? raw : {}) };
+ for (const option of options) next[option.key] = chosen.has(option.key);
+ return changeActionField(draft, source.path, next);
+ }
+ case 'auth':
+ // Applied by applyActionAssistPatch, which knows the type's allowed methods.
+ return null;
+ case 'mcp': {
+ const next = field.kind === 'lines' ? [...new Set(stringList(value))]
+ : field.kind === 'boolean' ? value
+ : value === null ? source.fallback : value;
+ if (field.kind === 'boolean' && typeof next !== 'boolean') return null;
+ if (field.kind === 'select' && !field.options?.some((option) => option.value === next)) return null;
+ return { ...draft, additionalFields: { ...draft.additionalFields, [source.key]: next } };
+ }
+ case 'endpoint':
+ return changeActionField(draft, '/endpoint', typeof value === 'string' ? value.trim() : '');
+ default:
+ break;
+ }
+ switch (field.kind) {
+ case 'boolean':
+ return typeof value === 'boolean' ? changeActionField(draft, field.path, value) : null;
+ case 'number':
+ if (value === null) return changeActionField(draft, field.path, undefined);
+ if (typeof value !== 'number' || !Number.isFinite(value) || (field.integer && !Number.isInteger(value))) return null;
+ return changeActionField(draft, field.path, value);
+ case 'select':
+ if (value === null || value === '') return changeActionField(draft, field.path, undefined);
+ if (typeof value !== 'string' || !field.options?.some((option) => option.value === value)) return null;
+ return changeActionField(draft, field.path, value);
+ case 'lines':
+ if (value === null) return changeActionField(draft, field.path, []);
+ if (!Array.isArray(value)) return null;
+ return changeActionField(draft, field.path, [...new Set(stringList(value))]);
+ default:
+ if (value === null) return changeActionField(draft, field.path, field.path.startsWith('/additionalFields/') ? undefined : '');
+ return typeof value === 'string' ? changeActionField(draft, field.path, value) : null;
+ }
+}
+
+function sameValue(current: unknown, value: unknown, kind: EditorAssistFieldKind): boolean {
+ if (kind === 'choices' && Array.isArray(current) && Array.isArray(value)) {
+ return JSON.stringify([...current].sort()) === JSON.stringify([...value].sort());
+ }
+ return JSON.stringify(current ?? null) === JSON.stringify(value ?? null);
+}
+
+/** The draft with no type, keeping the current type's configuration for a later switch back. */
+function clearActionType(draft: ActionConfiguration): ActionConfiguration {
+ const previous = isRecord(draft._actionTypeConfigurations) ? draft._actionTypeConfigurations : {};
+ return {
+ ...draft,
+ type: '', endpoint: '', auth: { type: 'NoAuth' }, identity_id: '', additionalFields: {},
+ _openApiSourceDraft: undefined, _sqlConnectionMethod: undefined,
+ _actionTypeConfigurations: {
+ ...previous,
+ [draft.type]: {
+ endpoint: draft.endpoint, auth: draft.auth, identity_id: draft.identity_id ?? '',
+ additionalFields: draft.additionalFields,
+ ...(draft._openApiSourceDraft ? { _openApiSourceDraft: draft._openApiSourceDraft } : {}),
+ ...(draft._sqlConnectionMethod ? { _sqlConnectionMethod: draft._sqlConnectionMethod } : {}),
+ },
+ },
+ };
+}
+
+/**
+ * The draft with a patch applied, or null when the patch can't be applied, such as a type that
+ * is no longer offered or a field the current type doesn't have. The variant path comes first in
+ * a patch, so a type change is applied before that type's fields.
+ */
+export function applyActionAssistPatch(
+ draft: ActionConfiguration,
+ patch: Readonly>,
+ context: ActionAssistContext,
+): ActionConfiguration | null {
+ let next = draft;
+ const entries = Object.entries(patch);
+ // Apply the type first even if the patch lists it later.
+ entries.sort(([left], [right]) => Number(right === '/type') - Number(left === '/type'));
+ for (const [path, value] of entries) {
+ switch (path) {
+ case '/displayName':
+ if (typeof value !== 'string') return null;
+ next = changeActionDisplayName(next, value, context.isNew);
+ continue;
+ case '/description':
+ if (value !== null && typeof value !== 'string') return null;
+ next = { ...next, description: actionText(value) };
+ continue;
+ case '/type': {
+ if (value === next.type || ((value === null || value === '') && !next.type)) continue;
+ if (value === null || value === '') {
+ // Only undo sends this: a new draft goes back to having no type.
+ next = clearActionType(next);
+ continue;
+ }
+ const definition = typeof value === 'string' ? definitionFor(value, context) : undefined;
+ if (!definition) return null;
+ next = changeActionType(next, definition);
+ continue;
+ }
+ default:
+ break;
+ }
+ const item = typeFields(next, context).find(({ field }) => field.path === path);
+ // Undo clears fields the turn added under another type; on this type they don't exist.
+ if (!item && value === null) continue;
+ if (!item) return null;
+ // An unchanged value is a no-op, so undo can restore read-only or legacy values.
+ if (sameValue(readValue(next, item), value, item.field.kind)) continue;
+ if (item.source.kind === 'auth') {
+ const definition = definitionFor(next.type, context) ?? fallbackDefinition(next);
+ const choice = actionAuthModes(next.type, definition.allowed_auth_types).find((mode) => mode.value === value);
+ if (!choice || item.field.read_only) return null;
+ next = changeActionAuth(next, choice);
+ continue;
+ }
+ const updated = writeValue(next, item, value);
+ if (!updated) return null;
+ next = updated;
+ }
+ return next;
+}
+
+/** Action types the agent assistant can draft. A Call agent action needs a target picked in the editor. */
+function draftableTypes(context: ActionAssistContext): ActionTypeDefinition[] {
+ return context.catalogue.filter((definition) => definition.type && definition.type !== 'agent');
+}
+
+/**
+ * How a new action looks to the agent assistant: the type choices, the fields every new action
+ * has, and each type's fields as they look on a fresh action of that type.
+ */
+export function newActionAssistSpec(context: ActionAssistContext): {
+ types: EditorAssistOption[];
+ common: EditorAssistField[];
+ variants: Record;
+} {
+ const types = draftableTypes(context);
+ const variants: Record = {};
+ for (const definition of types) {
+ const fresh = changeActionType(createActionDraft(), definition);
+ variants[definition.type] = describeType(fresh, definition, { ...context, isNew: true }).map(({ field }) => field);
+ }
+ return {
+ types: types.map((definition) => ({
+ value: definition.type, label: definition.display,
+ description: definition.description ? definition.description.slice(0, 900) : undefined,
+ })),
+ common: [
+ {
+ path: '/displayName', label: 'Action name', kind: 'text', required: true, max_length: DISPLAY_NAME_LIMIT,
+ help: 'The name agents and people see for this action.',
+ },
+ {
+ path: '/description', label: 'Description', kind: 'textarea', max_length: DESCRIPTION_LIMIT,
+ help: 'What this action does and when an agent should use it.',
+ },
+ ],
+ variants,
+ };
+}
+
+/** A new action of ``type`` with the assistant's values, or null when the type or a value isn't allowed. */
+export function draftActionFromAssist(
+ type: string, values: Readonly>, context: ActionAssistContext,
+): ActionConfiguration | null {
+ const definition = draftableTypes(context).find((item) => item.type === type);
+ if (!definition) return null;
+ const patch = { ...values };
+ delete patch['/type'];
+ return applyActionAssistPatch(changeActionType(createActionDraft(), definition), patch, { ...context, isNew: true });
+}
\ No newline at end of file
diff --git a/application/v2_ui/src/lib/agentEditorAssist.ts b/application/v2_ui/src/lib/agentEditorAssist.ts
new file mode 100644
index 000000000..3909594b4
--- /dev/null
+++ b/application/v2_ui/src/lib/agentEditorAssist.ts
@@ -0,0 +1,422 @@
+// agentEditorAssist.ts
+// How the agent editor describes its draft to Ask AI, and how it applies Ask AI's changes.
+//
+// The view lists only fields a person can set in the editor, with the same limits the editor
+// enforces. Connection details, keys and the raw settings JSON are never described. Applying a
+// patch goes through the editor's own helpers (renaming, model choice, knowledge), so a change
+// from Ask AI is the same change the person could have made by hand.
+
+import type {
+ EditorAssistField, EditorAssistNewItem, EditorAssistOption, EditorAssistSection, EditorAssistValues, EditorAssistView,
+} from './editorAssist';
+import { isRecord, type ActionConfiguration, type ActionTypeDefinition, type AgentConfiguration, type AgentEditorOptions } from './workspaceAuthoring';
+import { draftActionFromAssist, newActionAssistSpec } from './actionEditorAssist';
+import {
+ agentModelChoices, agentText, clearAgentDraftFields, renameAgentDraft, selectAgentModel, selectedAgentModel,
+} from './workspaceAgentAuthoring';
+import { agentActionLabel, agentActionUnavailableReason, resolveAgentAction } from './workspaceAgentActions';
+import {
+ KNOWLEDGE_LIMITS, knowledgeSourceKey, normalizeAgentKnowledgeUrl, readAgentKnowledge, selectedKnowledgeSources,
+ updateAgentKnowledge, type AgentKnowledgeCatalog, type AgentWebSource,
+} from './workspaceAgentKnowledge';
+import type { AgentTargetCatalog } from './agentDelegation';
+
+/** The editor's sections, in the order the editor shows them. */
+export const AGENT_ASSIST_SECTIONS: readonly EditorAssistSection[] = [
+ { id: 'identity', label: 'Identity' },
+ { id: 'model', label: 'Model & connection' },
+ { id: 'actions', label: 'Actions' },
+ { id: 'knowledge', label: 'Assigned knowledge' },
+ { id: 'instructions', label: 'Instructions' },
+ { id: 'advanced', label: 'Advanced' },
+];
+
+const MAX_DOCUMENT_OPTIONS = 500;
+const MAX_TAG_OPTIONS = 200;
+const MAX_ACTION_OPTIONS = 500;
+const DESCRIPTION_LIMIT = 1000;
+const INSTRUCTIONS_LIMIT = 20000;
+const DISPLAY_NAME_LIMIT = 100;
+const MAX_TOKENS = 512000;
+
+export interface AgentAssistContext {
+ readonly options: AgentEditorOptions;
+ readonly actions: readonly ActionConfiguration[];
+ readonly targets: AgentTargetCatalog | null;
+ readonly knowledge: AgentKnowledgeCatalog | null;
+ /** The knowledge source scopes this agent may use, such as personal and public. */
+ readonly knowledgeScopes: readonly string[];
+ /** Reasoning levels the selected model supports. */
+ readonly reasoningLevels: readonly string[];
+ readonly ownerId: string;
+ readonly isNew: boolean;
+ /** Action types the assistant may draft new actions of; null or empty when it can't create actions. */
+ readonly actionTypes?: readonly ActionTypeDefinition[] | null;
+ /** A fresh, session-unique reference for a drafted action. */
+ readonly newPendingReference?: () => string;
+ /** A drafted action removed earlier this session, so undo can bring it back. */
+ readonly recallPendingAction?: (reference: string) => ActionConfiguration | undefined;
+}
+
+const MAX_NEW_ACTIONS = 3;
+const PENDING_PREFIX = 'new-action-';
+
+/** An action Ask AI drafted for this agent. It is created when the agent is saved. */
+export interface PendingAgentAction {
+ readonly reference: string;
+ readonly action: ActionConfiguration;
+}
+
+export function isPendingActionReference(reference: string): boolean {
+ return reference.startsWith(PENDING_PREFIX);
+}
+
+/** A reference no saved action uses, for a newly drafted action. */
+export function newPendingActionReference(): string {
+ const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+ return `${PENDING_PREFIX}${random}`;
+}
+
+/** The drafted actions in an agent draft, in the order they were drafted. */
+export function pendingAgentActions(draft: AgentConfiguration): PendingAgentAction[] {
+ const raw = draft._pendingActions;
+ if (!Array.isArray(raw)) return [];
+ return raw.filter((item): item is PendingAgentAction => isRecord(item) && typeof item.reference === 'string' &&
+ isPendingActionReference(item.reference) && isRecord(item.action));
+}
+
+/**
+ * The draft with only the drafted actions it still assigns. Removing a drafted action's
+ * reference, by hand or by undo, discards that draft.
+ */
+export function prunePendingAgentActions(draft: AgentConfiguration): AgentConfiguration {
+ if (!Object.hasOwn(draft, '_pendingActions')) return draft;
+ const kept = pendingAgentActions(draft).filter((item) => draft.actions_to_load.includes(item.reference));
+ if (kept.length === (Array.isArray(draft._pendingActions) ? draft._pendingActions.length : -1)) return draft;
+ const next = { ...draft };
+ if (kept.length) next._pendingActions = kept;
+ else delete next._pendingActions;
+ return next;
+}
+
+/** The draft with a drafted action replaced by the saved action's identifier, keeping its position. */
+export function resolvePendingAgentAction(draft: AgentConfiguration, reference: string, savedId: string): AgentConfiguration {
+ const actions = draft.actions_to_load.flatMap((item) => item !== reference ? [item]
+ : draft.actions_to_load.includes(savedId) ? [] : [savedId]);
+ return prunePendingAgentActions({ ...draft, actions_to_load: actions });
+}
+
+function canDraftActions(context: AgentAssistContext): boolean {
+ return Boolean(context.actionTypes?.some((definition) => definition.type !== 'agent') && context.newPendingReference);
+}
+
+function webSourcesValue(sources: readonly AgentWebSource[]): { url: string; mode: string }[] {
+ return sources.map((source) => ({ url: source.url, mode: source.mode === 'deep_research' ? 'deep_research' : 'url_review' }));
+}
+
+/** The reference an action is assigned by, matching toggleAgentAction. */
+function actionReference(action: ActionConfiguration): string {
+ return action.id || action.name;
+}
+
+/** Every value the assistant may read, keyed by path. */
+export function agentAssistValues(draft: AgentConfiguration, context: AgentAssistContext): EditorAssistValues {
+ const values: Record = {
+ '/display_name': draft.display_name,
+ '/description': draft.description,
+ '/instructions': draft.instructions,
+ '/agent_type': draft.agent_type,
+ };
+ if (draft.agent_type !== 'local') return values;
+ const model = selectedAgentModel(draft, agentModelChoices(context.options));
+ values['/model'] = model?.key ?? null;
+ values['/actions'] = [...draft.actions_to_load];
+ values['/reasoning_effort'] = draft.reasoning_effort || null;
+ values['/max_completion_tokens'] = Number.isFinite(draft.max_completion_tokens) ? draft.max_completion_tokens : null;
+ const knowledge = readAgentKnowledge(draft);
+ values['/knowledge/enabled'] = knowledge.enabled;
+ values['/knowledge/web_sources'] = webSourcesValue(knowledge.web_sources);
+ if (context.knowledge) {
+ values['/knowledge/sources'] = selectedKnowledgeSources(knowledge);
+ values['/knowledge/documents'] = [...knowledge.document_ids];
+ values['/knowledge/tags'] = [...knowledge.tags];
+ }
+ return values;
+}
+
+function actionOptions(draft: AgentConfiguration, context: AgentAssistContext): EditorAssistOption[] {
+ const actions = [...context.actions];
+ const seen = new Set();
+ const options: EditorAssistOption[] = [];
+ for (const action of actions) {
+ // An assigned action keeps the reference the draft already uses, which may be its name.
+ const assignedAs = draft.actions_to_load.find((item) => resolveAgentAction(item, actions) === action);
+ const reference = assignedAs ?? actionReference(action);
+ if (!reference || seen.has(reference)) continue;
+ // An action that can't be newly assigned is offered only when it is already assigned,
+ // so the assistant can keep or remove it but never add it.
+ const blocked = agentActionUnavailableReason(draft, action, context.targets, context.ownerId);
+ if (blocked && !assignedAs) continue;
+ seen.add(reference);
+ const type = agentText(action.type);
+ const description = agentText(action.description).slice(0, 900);
+ options.push({
+ value: reference,
+ label: agentActionLabel(action),
+ description: [type ? `Type: ${type}.` : '', description].filter(Boolean).join(' ') || undefined,
+ });
+ if (options.length >= MAX_ACTION_OPTIONS) break;
+ }
+ for (const { reference, action } of pendingAgentActions(draft)) {
+ if (seen.has(reference)) continue;
+ seen.add(reference);
+ const description = agentText(action.description).slice(0, 700);
+ options.push({
+ value: reference,
+ label: agentText(action.displayName) || agentText(action.name) || 'New action',
+ description: [`New ${agentText(action.type)} action drafted in this session; it is created when the agent is saved.`, description]
+ .filter(Boolean).join(' '),
+ });
+ }
+ return options;
+}
+
+/** The draft as Ask AI sees it. */
+export function buildAgentAssistView(draft: AgentConfiguration, context: AgentAssistContext): EditorAssistView {
+ const local = draft.agent_type === 'local';
+ const fields: EditorAssistField[] = [
+ {
+ path: '/display_name', label: 'Display name', section: 'identity', kind: 'text', required: true,
+ max_length: DISPLAY_NAME_LIMIT, help: 'The name people see when they choose this agent.',
+ },
+ {
+ path: '/description', label: 'Description', section: 'identity', kind: 'textarea', required: true,
+ max_length: DESCRIPTION_LIMIT, help: 'What this agent does and when someone should choose it.',
+ },
+ {
+ path: '/agent_type', label: 'Agent type', section: 'identity', kind: 'select', read_only: true,
+ options: context.options.agent_types.map((type) => ({ value: type.value, label: type.label })),
+ help: 'Change the agent type in the editor yourself; Foundry types need connection details.',
+ },
+ {
+ path: '/instructions', label: 'Instructions', section: 'instructions', kind: 'textarea',
+ required: local, max_length: INSTRUCTIONS_LIMIT,
+ help: local ? "The agent's system prompt." : 'Kept for a local agent. A Foundry agent uses its own instructions.',
+ },
+ ];
+ const notes: string[] = [];
+ if (local) {
+ const models = agentModelChoices(context.options);
+ if (models.length) {
+ fields.push({
+ path: '/model', label: 'Model', section: 'model', kind: 'select',
+ options: models.map((choice) => ({ value: choice.key, label: choice.label })),
+ help: 'The model that answers for this agent.',
+ });
+ }
+ const actions = actionOptions(draft, context);
+ const drafting = canDraftActions(context);
+ if (actions.length || draft.actions_to_load.length || drafting) {
+ fields.push({
+ path: '/actions', label: 'Actions', section: 'actions', kind: 'choices', max_items: MAX_ACTION_OPTIONS,
+ options: actions, help: 'The actions this agent may call.',
+ });
+ } else {
+ notes.push('No actions are available to assign yet.');
+ }
+ fields.push({
+ path: '/max_completion_tokens', label: 'Completion token limit', section: 'advanced', kind: 'number',
+ min: -1, max: MAX_TOKENS, integer: true, required: true, help: '-1 uses the model default.',
+ });
+ const levels = [...context.reasoningLevels];
+ if (draft.reasoning_effort && !levels.includes(draft.reasoning_effort)) levels.push(draft.reasoning_effort);
+ if (levels.length) {
+ fields.push({
+ path: '/reasoning_effort', label: 'Reasoning effort', section: 'advanced', kind: 'select',
+ options: levels.map((level) => ({ value: level, label: level[0].toUpperCase() + level.slice(1) })),
+ help: 'Leave empty (null) to use the configured default.',
+ });
+ }
+ fields.push({
+ path: '/knowledge/enabled', label: 'Use assigned knowledge', section: 'knowledge', kind: 'boolean',
+ help: 'When on, the agent answers from the knowledge chosen below.',
+ });
+ if (context.knowledge) {
+ const sources = context.knowledge.sources.filter((source) => context.knowledgeScopes.includes(source.scope));
+ fields.push({
+ path: '/knowledge/sources', label: 'Knowledge workspaces', section: 'knowledge', kind: 'choices',
+ max_items: KNOWLEDGE_LIMITS.sources,
+ options: sources.slice(0, KNOWLEDGE_LIMITS.sources * 4).map((source) => ({
+ value: knowledgeSourceKey(source), label: source.label || source.id, description: `${source.scope} workspace`,
+ })),
+ help: 'The workspaces the agent may search.',
+ });
+ const documents = context.knowledge.documents.filter((document) =>
+ context.knowledgeScopes.includes(document.scope)).slice(0, MAX_DOCUMENT_OPTIONS);
+ if (documents.length) {
+ fields.push({
+ path: '/knowledge/documents', label: 'Specific documents', section: 'knowledge', kind: 'choices',
+ max_items: KNOWLEDGE_LIMITS.documents,
+ options: documents.map((document) => ({
+ value: document.id,
+ label: document.title || document.file_name || document.id,
+ description: [document.source_name, document.tags.length ? `Tags: ${document.tags.join(', ')}` : '']
+ .filter(Boolean).join('. ').slice(0, 900) || undefined,
+ })),
+ help: "Leave empty to use every document in the chosen workspaces. A document's workspace must be chosen too.",
+ });
+ }
+ const tags = context.knowledge.tags.slice(0, MAX_TAG_OPTIONS);
+ if (tags.length) {
+ fields.push({
+ path: '/knowledge/tags', label: 'Document tags', section: 'knowledge', kind: 'choices',
+ max_items: KNOWLEDGE_LIMITS.tags,
+ options: tags.map((tag) => ({ value: tag.name, label: tag.name, description: `${tag.count} documents` })),
+ help: 'Limit the agent to documents with every chosen tag.',
+ });
+ }
+ } else {
+ notes.push("The knowledge catalogue hasn't loaded, so workspaces, documents and tags can't be chosen yet.");
+ }
+ fields.push({
+ path: '/knowledge/web_sources', label: 'Web pages', section: 'knowledge', kind: 'web_sources',
+ max_items: KNOWLEDGE_LIMITS.urls, help: 'Only add pages the person named.',
+ });
+ } else {
+ notes.push('This is a Foundry agent: its model, tools and knowledge are managed in Foundry, so only identity and description can change here.');
+ }
+ let newItems: EditorAssistView['newItems'];
+ if (local && canDraftActions(context)) {
+ const spec = newActionAssistSpec({ catalogue: context.actionTypes ?? [], isNew: true });
+ const room = Math.max(0, MAX_NEW_ACTIONS - pendingAgentActions(draft).length);
+ newItems = { target: '/actions', noun: 'action', max: room, ...spec };
+ notes.push(room
+ ? `Ask AI can draft up to ${room} new action${room === 1 ? '' : 's'} when no existing action fits; prefer assigning an existing one. New actions are created only when the person saves the agent.`
+ : 'This draft already has the most new actions Ask AI can draft at once. Save the agent or remove one first.');
+ notes.push('Ask AI never enters keys, passwords or other credentials. The person finishes a new action that needs them in the action editor. Call agent actions are created in the action editor.');
+ } else if (local) {
+ notes.push('Ask AI can only assign existing actions here; new actions are created in the action editor.');
+ }
+ return {
+ sections: AGENT_ASSIST_SECTIONS,
+ fields,
+ values: agentAssistValues(draft, context),
+ ...(newItems ? { newItems } : {}),
+ notes,
+ };
+}
+
+function strings(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
+}
+
+/**
+ * The draft with a patch applied, or null when the patch can't be applied, such as a model
+ * that is no longer offered. ``newItems`` are actions the assistant drafted this turn; each gets
+ * a session-unique reference, and its turn handle in the patch is replaced by that reference.
+ */
+export function applyAgentAssistPatch(
+ draft: AgentConfiguration,
+ patch: Readonly>,
+ context: AgentAssistContext,
+ newItems: readonly EditorAssistNewItem[] = [],
+): AgentConfiguration | null {
+ let next = draft;
+ const handles = new Map();
+ const drafted: PendingAgentAction[] = [];
+ for (const item of newItems) {
+ if (!canDraftActions(context) || !context.newPendingReference) return null;
+ const action = draftActionFromAssist(item.type, item.values, { catalogue: context.actionTypes ?? [], isNew: true });
+ if (!action) return null;
+ const reference = context.newPendingReference();
+ handles.set(item.handle, reference);
+ drafted.push({ reference, action });
+ }
+ if (drafted.length && pendingAgentActions(draft).length + drafted.length > MAX_NEW_ACTIONS) return null;
+ for (const [path, value] of Object.entries(patch)) {
+ switch (path) {
+ case '/display_name':
+ next = renameAgentDraft(next, agentText(value), context.isNew);
+ break;
+ case '/description':
+ next = { ...next, description: agentText(value) };
+ break;
+ case '/instructions':
+ next = { ...next, instructions: agentText(value) };
+ break;
+ case '/model': {
+ const choice = agentModelChoices(context.options).find((item) => item.key === value);
+ if (!choice) return null;
+ next = selectAgentModel(next, choice);
+ break;
+ }
+ case '/actions': {
+ const pending = [...pendingAgentActions(next), ...drafted];
+ const references: string[] = [];
+ for (const raw of strings(value)) {
+ const reference = handles.get(raw) ?? raw;
+ if (raw.startsWith('new:') && !handles.has(raw)) return null;
+ if (isPendingActionReference(reference) && !pending.some((item) => item.reference === reference)) {
+ // Undo can assign a drafted action an earlier turn removed.
+ const recalled = context.recallPendingAction?.(reference);
+ if (!recalled) return null;
+ pending.push({ reference, action: recalled });
+ }
+ if (!references.includes(reference)) references.push(reference);
+ }
+ next = { ...next, actions_to_load: references, ...(pending.length ? { _pendingActions: pending } : {}) };
+ break;
+ }
+ case '/reasoning_effort': {
+ const effort = agentText(value);
+ const updated = { ...next };
+ if (effort) updated.reasoning_effort = effort;
+ else delete updated.reasoning_effort;
+ next = updated;
+ break;
+ }
+ case '/max_completion_tokens': {
+ if (typeof value !== 'number' || !Number.isInteger(value)) return null;
+ next = clearAgentDraftFields({ ...next, max_completion_tokens: value }, '_editor_completion_text');
+ break;
+ }
+ case '/knowledge/enabled':
+ next = updateAgentKnowledge(next, { enabled: value === true });
+ break;
+ case '/knowledge/sources': {
+ const keys = strings(value);
+ const scopes = { ...readAgentKnowledge(next).scopes };
+ scopes.personal = keys.some((key) => key.startsWith('personal:'));
+ scopes.group_ids = keys.filter((key) => key.startsWith('group:')).map((key) => key.slice('group:'.length));
+ scopes.public_workspace_ids = keys.filter((key) => key.startsWith('public:')).map((key) => key.slice('public:'.length));
+ next = updateAgentKnowledge(next, { scopes });
+ break;
+ }
+ case '/knowledge/documents':
+ next = updateAgentKnowledge(next, { document_ids: [...new Set(strings(value))] });
+ break;
+ case '/knowledge/tags':
+ next = updateAgentKnowledge(next, { tags: [...new Set(strings(value))] });
+ break;
+ case '/knowledge/web_sources': {
+ if (!Array.isArray(value)) return null;
+ const existing = new Map(readAgentKnowledge(next).web_sources.map((source) => [source.url, source]));
+ const sources: AgentWebSource[] = [];
+ for (const item of value) {
+ if (!item || typeof item !== 'object') return null;
+ const raw = agentText((item as Record).url);
+ const url = existing.has(raw) ? raw : normalizeAgentKnowledgeUrl(raw);
+ if (!url) return null;
+ const mode = (item as Record).mode === 'deep_research' ? 'deep_research' : 'url_review';
+ sources.push({ ...(existing.get(url) ?? {}), url, mode });
+ }
+ next = updateAgentKnowledge(next, { web_sources: sources });
+ break;
+ }
+ default:
+ return null;
+ }
+ }
+ return prunePendingAgentActions(next);
+}
diff --git a/application/v2_ui/src/lib/agentWorkbench.ts b/application/v2_ui/src/lib/agentWorkbench.ts
index dfa3eaec2..5c7931261 100644
--- a/application/v2_ui/src/lib/agentWorkbench.ts
+++ b/application/v2_ui/src/lib/agentWorkbench.ts
@@ -46,7 +46,9 @@ import {
} from './agentDelegation';
import type { AgentLinkScope } from './conversationUrl';
import type { EditorWorkspaceScope } from './workspaceEditorDrafts';
-import { fetchGlobalActions, type ActionWorkbenchAdapter } from './actionWorkbench';
+import {
+ fetchGlobalActions, GLOBAL_ACTION_WORKBENCH, PERSONAL_ACTION_WORKBENCH, type ActionWorkbenchAdapter,
+} from './actionWorkbench';
import { requireWorkspaceId, workspaceBasePath } from './workspaceContext';
export type AgentWorkbenchScope =
@@ -73,6 +75,8 @@ export interface AgentWorkbenchAdapter {
actionsBasePath: string;
/** Whether "New action" is offered. Personal reads this from bootstrap; group precomputes it here. */
canCreateActions: boolean;
+ /** The matching action workbench, so Ask AI's drafted actions save into the same scope. */
+ actionWorkbench: ActionWorkbenchAdapter | null;
supported: ReadonlySet;
allows: (operation: AgentOperation, agent?: AgentConfiguration) => boolean;
/** Whether this agent may be launched into chat: a per-agent server hint in group scope. */
@@ -169,6 +173,7 @@ export const PERSONAL_AGENT_WORKBENCH: AgentWorkbenchAdapter = {
// Personal action-creation eligibility comes from the bootstrap store in the editor; this
// value is unused in personal scope and never gates the group editor.
canCreateActions: false,
+ actionWorkbench: PERSONAL_ACTION_WORKBENCH,
supported: new Set(AGENT_OPERATIONS),
allows: () => true,
canUseInChat: (agent) => agent.is_enabled !== false,
@@ -282,6 +287,7 @@ export function createGroupAgentWorkbench(
// candidates route dark and offers no creation, never falling back to a personal action.
actionsBasePath: actionAdapter ? actionAdapter.basePath : `${workspaceBasePath(scope)}/actions`,
canCreateActions: actionAdapter ? actionAdapter.allows('create') : false,
+ actionWorkbench: actionAdapter,
supported,
allows,
canUseInChat: (agent) => Array.isArray(agent.agent_actions) && agent.agent_actions.includes('chat'),
@@ -395,6 +401,7 @@ export const GLOBAL_AGENT_WORKBENCH: AgentWorkbenchAdapter = {
delegationScope: GLOBAL_DELEGATION_SCOPE,
actionsBasePath: GLOBAL_ACTIONS_BASE_PATH,
canCreateActions: true,
+ actionWorkbench: GLOBAL_ACTION_WORKBENCH,
supported: new Set(AGENT_OPERATIONS),
allows: () => true,
// Whether people can reach a global agent depends on Workspace Mode and the merge setting, so
diff --git a/application/v2_ui/src/lib/editorAssist.ts b/application/v2_ui/src/lib/editorAssist.ts
new file mode 100644
index 000000000..805fe7b44
--- /dev/null
+++ b/application/v2_ui/src/lib/editorAssist.ts
@@ -0,0 +1,635 @@
+// editorAssist.ts
+// The agent and action editors' side of POST /api/agents/assist and /api/actions/assist: the view
+// of the draft a request sends, the answer it accepts, and what a failure means for the reader.
+//
+// The editor describes its draft as a flat list of fields, each with a JSON-pointer-like path, and
+// the server answers with a candidate value for each path. Everything the server sends back is
+// untrusted: the answer is checked field by field, the change list is recomputed here from the
+// values, and every string is plain text for the UI to render as a text node.
+
+import { apiUrl, CREDENTIALS_MODE } from './apiClient';
+import { codePointLength, codePointPrefix } from './codePoints';
+import { isRecord } from './workspaceAuthoring';
+
+export type EditorAssistKind = 'agent' | 'action';
+
+export const EDITOR_ASSIST_PATHS: Readonly> = {
+ agent: '/api/agents/assist',
+ action: '/api/actions/assist',
+};
+
+/** The server counts the instruction in code points (EDITOR_ASSIST_INSTRUCTION_MAX_LENGTH). */
+export const EDITOR_ASSIST_INSTRUCTION_LIMIT = 2000;
+export const EDITOR_ASSIST_TURN_TEXT_LIMIT = 4000;
+/** Conversation items: ten whole exchanges, so a replay never starts with an orphan reply. */
+export const EDITOR_ASSIST_MAX_TURNS = 20;
+export const EDITOR_ASSIST_REPLY_LIMIT = 2000;
+export const EDITOR_ASSIST_MAX_NOTES = 10;
+export const EDITOR_ASSIST_NOTE_LIMIT = 300;
+/** The server stops at about 150 seconds; the browser waits a little longer for its answer. */
+export const EDITOR_ASSIST_DEADLINE_MS = 170_000;
+/** The prefix of a choice value that names an item the assistant drafted in this turn. */
+export const EDITOR_ASSIST_NEW_ITEM_PREFIX = 'new:';
+
+const SERVER_TEXT_LIMIT = 1000;
+const LABEL_LIMIT = 200;
+const MAX_CHANGES = 300;
+const MAX_WARNINGS = 20;
+const MAX_NEW_ITEMS = 5;
+const MAX_RETRY_AFTER_SECONDS = 3600;
+const REPLAY_LABELS = 12;
+const REPLAY_LABEL_LIMIT = 120;
+const PATH_PATTERN = /^\/[A-Za-z0-9_/]{1,200}$/;
+const SECTION_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
+const HANDLE_PATTERN = /^new:[A-Za-z0-9_-]{1,32}$/;
+
+// The characters the server refuses in text. Tab, LF and CR are allowed.
+const CONTROL_CHARACTERS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
+const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?>;
+}
+
+/** New items the agent assistant may draft and assign, such as actions. */
+export interface EditorAssistNewItemsSpec {
+ readonly target: string;
+ readonly noun: string;
+ readonly max: number;
+ readonly types: readonly EditorAssistOption[];
+ readonly common: readonly EditorAssistField[];
+ readonly variants?: Readonly>;
+}
+
+export type EditorAssistValues = Readonly>;
+
+/** The draft as the assistant sees it. */
+export interface EditorAssistView {
+ readonly sections: readonly EditorAssistSection[];
+ readonly fields: readonly EditorAssistField[];
+ readonly values: EditorAssistValues;
+ readonly variant?: EditorAssistVariant;
+ readonly newItems?: EditorAssistNewItemsSpec;
+ readonly notes?: readonly string[];
+}
+
+export interface EditorAssistScope {
+ readonly kind: 'personal' | 'group' | 'global';
+ readonly id?: string;
+}
+
+export interface EditorAssistConversationItem {
+ readonly role: 'user' | 'assistant';
+ readonly text: string;
+}
+
+export interface EditorAssistRequest {
+ readonly scope: EditorAssistScope['kind'];
+ readonly group_id?: string;
+ readonly submission_id: string;
+ readonly instruction: string;
+ readonly conversation: readonly EditorAssistConversationItem[];
+ readonly focus?: string;
+ readonly sections: readonly EditorAssistSection[];
+ readonly fields: readonly EditorAssistField[];
+ readonly values: EditorAssistValues;
+ readonly variant?: EditorAssistVariant;
+ readonly new_items?: EditorAssistNewItemsSpec;
+ readonly notes?: readonly string[];
+}
+
+export interface EditorAssistNewItem {
+ readonly handle: string;
+ readonly type: string;
+ readonly values: EditorAssistValues;
+}
+
+export interface EditorAssistChange {
+ readonly path: string;
+ readonly label: string;
+ readonly section: string | null;
+}
+
+export interface EditorAssistWarning {
+ readonly code: string;
+ readonly message: string;
+ readonly paths: readonly string[];
+}
+
+export interface EditorAssistResponse {
+ readonly submissionId: string;
+ readonly outcome: 'changed' | 'explained';
+ readonly reply: string;
+ readonly candidate: { readonly values: EditorAssistValues; readonly newItems: readonly EditorAssistNewItem[] } | null;
+ readonly changes: readonly EditorAssistChange[];
+ readonly warnings: readonly EditorAssistWarning[];
+}
+
+export interface EditorAssistFailure {
+ readonly status: number;
+ readonly code: string;
+ readonly message: string;
+ /** Wait this long before sending again: a 429, or a throttled 503. */
+ readonly retryAfterSeconds: number | null;
+}
+
+export type EditorAssistPostResult =
+ | { readonly ok: true; readonly response: EditorAssistResponse }
+ | { readonly ok: false; readonly aborted: true }
+ | { readonly ok: false; readonly aborted?: false; readonly failure: EditorAssistFailure };
+
+// ---------------------------------------------------------------------------------------------
+// Values
+// ---------------------------------------------------------------------------------------------
+
+/** Text the server accepts: no control characters except tab, LF and CR, and no lone surrogate. */
+export function editorAssistText(value: string): string {
+ return value.replace(CONTROL_CHARACTERS, '').replace(LONE_SURROGATE, '\ufffd');
+}
+
+function stable(value: unknown, depth = 0): unknown {
+ if (depth > 40) return null;
+ if (Array.isArray(value)) return value.map((item) => stable(item, depth + 1));
+ if (isRecord(value)) {
+ const result: Record = {};
+ for (const key of Object.keys(value).sort()) {
+ if (value[key] !== undefined) result[key] = stable(value[key], depth + 1);
+ }
+ return result;
+ }
+ if (typeof value === 'number' && !Number.isFinite(value)) return null;
+ return value === undefined ? null : value;
+}
+
+/** A value's JSON with sorted keys, so two equal values always compare equal. */
+export function editorAssistKey(value: unknown): string {
+ return JSON.stringify(stable(value)) ?? 'null';
+}
+
+export function sameEditorAssistValue(left: unknown, right: unknown): boolean {
+ return editorAssistKey(left) === editorAssistKey(right);
+}
+
+/** Whether a value counts as empty, as the server's `_is_blank` does. */
+export function editorAssistBlank(value: unknown): boolean {
+ if (value === null || value === undefined) return true;
+ if (typeof value === 'string') return !value.trim();
+ if (Array.isArray(value)) return value.length === 0;
+ return false;
+}
+
+/** Every field of the view, including each variant's and new-item fields, by path. */
+export function editorAssistFieldIndex(view: EditorAssistView): Map {
+ const index = new Map();
+ for (const field of view.fields) index.set(field.path, field);
+ for (const fields of Object.values(view.variant?.fields ?? {})) {
+ for (const field of fields) if (!index.has(field.path)) index.set(field.path, field);
+ }
+ return index;
+}
+
+/** The fields active for `values`: the common ones and the selected variant's. */
+export function editorAssistActiveFields(view: EditorAssistView, values: EditorAssistValues): EditorAssistField[] {
+ const active = [...view.fields];
+ if (view.variant) {
+ const selected = values[view.variant.path];
+ if (typeof selected === 'string') active.push(...(view.variant.fields[selected] ?? []));
+ }
+ return active;
+}
+
+// ---------------------------------------------------------------------------------------------
+// Request
+// ---------------------------------------------------------------------------------------------
+
+export class EditorAssistRequestError extends Error {}
+
+export interface EditorAssistRequestInput {
+ readonly scope: EditorAssistScope;
+ readonly submissionId: string;
+ readonly instruction: string;
+ readonly view: EditorAssistView;
+ readonly conversation: readonly EditorAssistConversationItem[];
+ readonly focus?: string | null;
+}
+
+function cappedNote(note: string): string {
+ const text = editorAssistText(note).replace(/\s+/g, ' ').trim();
+ return codePointPrefix(text, EDITOR_ASSIST_NOTE_LIMIT);
+}
+
+export function buildEditorAssistRequest(input: EditorAssistRequestInput): EditorAssistRequest {
+ const instruction = editorAssistText(input.instruction).trim();
+ if (!instruction) throw new EditorAssistRequestError('Type what you want Ask AI to do.');
+ if (codePointLength(instruction) > EDITOR_ASSIST_INSTRUCTION_LIMIT) {
+ throw new EditorAssistRequestError(`Keep the request to ${EDITOR_ASSIST_INSTRUCTION_LIMIT} characters or fewer.`);
+ }
+ const { view, scope } = input;
+ if (scope.kind === 'group' && !scope.id) throw new EditorAssistRequestError("Ask AI couldn't tell which group this is.");
+ const sectionIds = new Set(view.sections.map((section) => section.id));
+ const values: Record = {};
+ for (const field of editorAssistActiveFields(view, view.values)) {
+ if (Object.hasOwn(view.values, field.path)) values[field.path] = view.values[field.path];
+ }
+ const notes = (view.notes ?? []).map(cappedNote).filter(Boolean).slice(0, EDITOR_ASSIST_MAX_NOTES);
+ const focus = input.focus && sectionIds.has(input.focus) ? input.focus : undefined;
+ return {
+ scope: scope.kind,
+ ...(scope.kind === 'group' && scope.id ? { group_id: scope.id } : {}),
+ submission_id: input.submissionId,
+ instruction,
+ conversation: input.conversation.slice(-EDITOR_ASSIST_MAX_TURNS),
+ ...(focus ? { focus } : {}),
+ sections: view.sections,
+ fields: view.fields,
+ values,
+ ...(view.variant ? { variant: view.variant } : {}),
+ ...(view.newItems ? { new_items: view.newItems } : {}),
+ ...(notes.length ? { notes } : {}),
+ };
+}
+
+// ---------------------------------------------------------------------------------------------
+// Conversation replay
+// ---------------------------------------------------------------------------------------------
+
+export type EditorAssistTurnState = 'none' | 'applied' | 'partly_undone' | 'undone';
+
+export interface EditorAssistReplayTurn {
+ readonly instruction: string;
+ readonly reply: string;
+ readonly state: EditorAssistTurnState;
+ readonly changes: readonly string[];
+}
+
+function clipped(value: string, limit: number): string {
+ const text = value.replace(/\s+/g, ' ').trim();
+ return codePointLength(text) > limit ? `${codePointPrefix(text, limit - 1).trimEnd()}…` : text;
+}
+
+function replaySuffix(turn: EditorAssistReplayTurn): string {
+ const labels = turn.changes.map((label) => clipped(editorAssistText(label), REPLAY_LABEL_LIMIT)).filter(Boolean);
+ const shown = labels.slice(0, REPLAY_LABELS);
+ const more = labels.length - shown.length;
+ const list = shown.length ? `: ${shown.join('; ')}${more > 0 ? `; and ${more} more` : ''}.` : '.';
+ switch (turn.state) {
+ case 'applied':
+ return `Changes applied${list}`;
+ case 'partly_undone':
+ return `Changes applied${list} The user later undid some of them.`;
+ case 'undone':
+ return `These changes were applied and later undone${list}`;
+ default:
+ return '';
+ }
+}
+
+function replayItem(role: 'user' | 'assistant', text: string, suffix = ''): EditorAssistConversationItem | null {
+ const body = editorAssistText(text).trim();
+ const combined = suffix ? (body ? `${body}\n\n${suffix}` : suffix) : body;
+ const capped = codePointPrefix(combined, EDITOR_ASSIST_TURN_TEXT_LIMIT);
+ return capped.trim() ? { role, text: capped } : null;
+}
+
+/** The last ten completed exchanges, oldest first; each reply says what became of its changes. */
+export function editorAssistConversation(turns: readonly EditorAssistReplayTurn[]): EditorAssistConversationItem[] {
+ const items: EditorAssistConversationItem[] = [];
+ for (const turn of turns.slice(-(EDITOR_ASSIST_MAX_TURNS / 2))) {
+ const user = replayItem('user', turn.instruction);
+ const assistant = replayItem('assistant', turn.reply, replaySuffix(turn));
+ if (user) items.push(user);
+ if (assistant) items.push(assistant);
+ }
+ return items.slice(-EDITOR_ASSIST_MAX_TURNS);
+}
+
+// ---------------------------------------------------------------------------------------------
+// Response
+// ---------------------------------------------------------------------------------------------
+
+function serverText(value: unknown, limit: number): string | null {
+ if (typeof value !== 'string') return null;
+ const text = editorAssistText(value).trim();
+ return text ? codePointPrefix(text, limit) : null;
+}
+
+function validPath(value: unknown): value is string {
+ return typeof value === 'string' && PATH_PATTERN.test(value);
+}
+
+function parseValues(value: unknown): Record | null {
+ if (!isRecord(value)) return null;
+ const result: Record = {};
+ for (const [path, item] of Object.entries(value)) {
+ if (!validPath(path)) return null;
+ result[path] = item;
+ }
+ return result;
+}
+
+function parseNewItem(value: unknown): EditorAssistNewItem | null {
+ if (!isRecord(value)) return null;
+ const { handle, type } = value;
+ if (typeof handle !== 'string' || !HANDLE_PATTERN.test(handle)) return null;
+ if (typeof type !== 'string' || !type || type.length > 128) return null;
+ const values = parseValues(value.values);
+ return values ? { handle, type, values } : null;
+}
+
+function parseChange(value: unknown): EditorAssistChange | null {
+ if (!isRecord(value) || !validPath(value.path)) return null;
+ const label = serverText(value.label, LABEL_LIMIT) ?? value.path;
+ const section = typeof value.section === 'string' && SECTION_PATTERN.test(value.section) ? value.section : null;
+ return { path: value.path, label, section };
+}
+
+function parseWarning(value: unknown): EditorAssistWarning | null {
+ if (!isRecord(value)) return null;
+ const code = typeof value.code === 'string' && /^[a-z0-9_]{1,64}$/.test(value.code) ? value.code : null;
+ const message = serverText(value.message, SERVER_TEXT_LIMIT);
+ if (!code || !message) return null;
+ const paths = Array.isArray(value.paths) ? value.paths.filter(validPath).slice(0, 50) : [];
+ return { code, message, paths };
+}
+
+/** The answer, checked field by field, or null when it can't be used. */
+export function parseEditorAssistResponse(value: unknown, submissionId: string): EditorAssistResponse | null {
+ if (!isRecord(value) || value.submission_id !== submissionId) return null;
+ const outcome = value.outcome;
+ if (outcome !== 'changed' && outcome !== 'explained') return null;
+ const reply = serverText(value.reply, EDITOR_ASSIST_REPLY_LIMIT);
+ if (!reply) return null;
+ let candidate: EditorAssistResponse['candidate'] = null;
+ if (outcome === 'changed') {
+ if (!isRecord(value.candidate)) return null;
+ const values = parseValues(value.candidate.values);
+ const rawItems = value.candidate.new_items ?? [];
+ if (!values || !Array.isArray(rawItems) || rawItems.length > MAX_NEW_ITEMS) return null;
+ const newItems: EditorAssistNewItem[] = [];
+ for (const raw of rawItems) {
+ const item = parseNewItem(raw);
+ if (!item || newItems.some((existing) => existing.handle === item.handle)) return null;
+ newItems.push(item);
+ }
+ candidate = { values, newItems };
+ } else if (value.candidate !== null && value.candidate !== undefined) {
+ return null;
+ }
+ const changes = Array.isArray(value.changes)
+ ? value.changes.slice(0, MAX_CHANGES).map(parseChange).filter((item): item is EditorAssistChange => item !== null)
+ : [];
+ const warnings = Array.isArray(value.warnings)
+ ? value.warnings.slice(0, MAX_WARNINGS).map(parseWarning).filter((item): item is EditorAssistWarning => item !== null)
+ : [];
+ return { submissionId, outcome, reply, candidate, changes, warnings };
+}
+
+const STATUS_FALLBACKS: Readonly> = {
+ 400: "The assistant couldn't use this request. Nothing was changed.",
+ 401: 'Your session expired. Sign in again to use Ask AI.',
+ 403: "Ask AI isn't available to you right now.",
+ 404: "This workspace couldn't be found.",
+ 413: 'This request is too large for the assistant. Ask for a smaller change, or start a new thread.',
+ 429: 'The assistant is busy. Wait a moment, then try again.',
+ 500: 'The assistant failed. Nothing was changed. Try again.',
+ 502: "The assistant's answer couldn't be used. Nothing was changed. Try again.",
+ 503: 'The assistant is unavailable right now. Nothing was changed. Try again later.',
+};
+
+function clampRetryAfter(seconds: number): number {
+ return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.ceil(seconds)));
+}
+
+/** Seconds to wait, from a `Retry-After` header (seconds or an HTTP date), or else the body. */
+export function editorAssistRetryAfter(header: string | null, body: unknown, now = Date.now()): number | null {
+ const text = (header ?? '').trim();
+ if (/^\d{1,10}$/.test(text)) return clampRetryAfter(Number(text));
+ if (text) {
+ const date = Date.parse(text);
+ if (!Number.isNaN(date)) return clampRetryAfter((date - now) / 1000);
+ }
+ const seconds = isRecord(body) ? body.retry_after_seconds : undefined;
+ return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? clampRetryAfter(seconds) : null;
+}
+
+const ONE_WORD_ERROR = /^[A-Za-z][A-Za-z0-9_]*$/;
+
+/** What a failed request means for the reader. The server's text is shown as plain text. */
+export function describeEditorAssistError(status: number, body: unknown, retryAfterHeader: string | null = null): EditorAssistFailure {
+ const code = isRecord(body) && typeof body.code === 'string' && /^[a-z0-9_]{1,64}$/.test(body.code) ? body.code : '';
+ const fallback = STATUS_FALLBACKS[status] ?? `The assistant request failed (status ${status}). Nothing was changed.`;
+ const error = isRecord(body) ? serverText(body.error, SERVER_TEXT_LIMIT) : null;
+ const sentence = isRecord(body) ? serverText(body.message, SERVER_TEXT_LIMIT) : null;
+ const message = (error && ONE_WORD_ERROR.test(error) && sentence ? sentence : error) || fallback;
+ const retryAfterSeconds = status === 429 || status === 503 ? editorAssistRetryAfter(retryAfterHeader, body) : null;
+ return {
+ status,
+ code,
+ message,
+ retryAfterSeconds: status === 429 && retryAfterSeconds === null ? 1 : retryAfterSeconds,
+ };
+}
+
+/**
+ * POST one turn. A cancel through `signal` is `aborted`; the browser deadline and a lost
+ * connection are failures the reader can retry.
+ */
+export async function postEditorAssist(
+ kind: EditorAssistKind,
+ body: EditorAssistRequest,
+ signal: AbortSignal,
+ deadlineMs = EDITOR_ASSIST_DEADLINE_MS,
+): Promise {
+ const controller = new AbortController();
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, deadlineMs);
+ const stop = () => controller.abort();
+ if (signal.aborted) controller.abort();
+ else signal.addEventListener('abort', stop, { once: true });
+ try {
+ // A raw fetch rather than apiClient: ApiError drops the headers, and a throttled 429 carries
+ // its wait in Retry-After.
+ const response = await fetch(apiUrl(EDITOR_ASSIST_PATHS[kind]), {
+ method: 'POST',
+ credentials: CREDENTIALS_MODE,
+ signal: controller.signal,
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const text = await response.text();
+ let payload: unknown = null;
+ try {
+ payload = text ? JSON.parse(text) : null;
+ } catch {
+ payload = null;
+ }
+ if (signal.aborted) return { ok: false, aborted: true };
+ if (!response.ok) {
+ return { ok: false, failure: describeEditorAssistError(response.status, payload, response.headers.get('Retry-After')) };
+ }
+ const parsed = parseEditorAssistResponse(payload, body.submission_id);
+ return parsed ? { ok: true, response: parsed } : {
+ ok: false,
+ failure: { status: response.status, code: 'unreadable', message: EDITOR_ASSIST_UNREADABLE_MESSAGE, retryAfterSeconds: null },
+ };
+ } catch {
+ if (signal.aborted) return { ok: false, aborted: true };
+ return {
+ ok: false,
+ failure: timedOut ? {
+ status: 0, code: 'browser_timeout', retryAfterSeconds: null,
+ message: 'The assistant took too long to answer. Nothing was changed. Try again, or ask for a smaller change.',
+ } : {
+ status: 0, code: 'network_error', retryAfterSeconds: null,
+ message: "Couldn't reach the assistant. Nothing was changed. Check your connection and try again.",
+ },
+ };
+ } finally {
+ clearTimeout(timer);
+ signal.removeEventListener('abort', stop);
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Applying and undoing
+// ---------------------------------------------------------------------------------------------
+
+/** One path a turn changed: its value before the turn and the value the turn left. */
+export interface EditorAssistEntry {
+ readonly path: string;
+ readonly before: unknown;
+ readonly after: unknown;
+}
+
+export interface EditorAssistTurnChange {
+ readonly path: string;
+ readonly label: string;
+ readonly section: string | null;
+}
+
+/**
+ * The patch a candidate makes to the live values: every path whose value differs. A changed
+ * variant comes first, because it decides which other fields exist.
+ */
+export function editorAssistPatch(
+ view: EditorAssistView,
+ live: EditorAssistValues,
+ candidate: EditorAssistValues,
+): Record {
+ const active = new Set([
+ ...editorAssistActiveFields(view, live).map((field) => field.path),
+ ...editorAssistActiveFields(view, candidate).map((field) => field.path),
+ ]);
+ const index = editorAssistFieldIndex(view);
+ const patch: Record = {};
+ const variantPath = view.variant?.path;
+ if (variantPath && Object.hasOwn(candidate, variantPath) && !sameEditorAssistValue(live[variantPath], candidate[variantPath])) {
+ patch[variantPath] = candidate[variantPath];
+ }
+ for (const [path, value] of Object.entries(candidate)) {
+ if (path === variantPath || !active.has(path)) continue;
+ if (index.get(path)?.read_only) continue;
+ if (sameEditorAssistValue(live[path], value)) continue;
+ if (editorAssistBlank(live[path]) && editorAssistBlank(value)) continue;
+ patch[path] = value;
+ }
+ return patch;
+}
+
+/**
+ * The changes a turn made, labelled from the view. A field that went away with the old type is
+ * left out: the type change itself says so.
+ */
+export function editorAssistTurnChanges(view: EditorAssistView, entries: readonly EditorAssistEntry[]): EditorAssistTurnChange[] {
+ const index = editorAssistFieldIndex(view);
+ return entries
+ .filter((entry) => entry.after !== undefined && !sameEditorAssistValue(entry.before, entry.after))
+ .map((entry) => {
+ const field = index.get(entry.path);
+ return { path: entry.path, label: field?.label ?? entry.path, section: field?.section ?? null };
+ });
+}
+
+export interface EditorAssistUndoPlan {
+ readonly patch: Record;
+ readonly reverted: readonly string[];
+ readonly skipped: readonly string[];
+}
+
+/**
+ * What undoing a turn does now: each path still holding the turn's value goes back; a path
+ * changed again since is skipped. When the turn changed the variant and that goes back, the old
+ * variant's fields go back too.
+ */
+export function editorAssistUndoPlan(
+ entries: readonly EditorAssistEntry[],
+ before: EditorAssistValues,
+ live: EditorAssistValues,
+ variantPath?: string,
+): EditorAssistUndoPlan {
+ const patch: Record = {};
+ const reverted: string[] = [];
+ const skipped: string[] = [];
+ for (const entry of entries) {
+ if (sameEditorAssistValue(live[entry.path], entry.before)) continue;
+ if (!sameEditorAssistValue(live[entry.path], entry.after)) {
+ skipped.push(entry.path);
+ continue;
+ }
+ patch[entry.path] = entry.before;
+ reverted.push(entry.path);
+ }
+ if (variantPath && Object.hasOwn(patch, variantPath)) {
+ const ordered: Record = { [variantPath]: patch[variantPath] };
+ for (const [path, value] of Object.entries(before)) {
+ if (path !== variantPath && !Object.hasOwn(patch, path) && !entries.some((entry) => entry.path === path)) ordered[path] = value;
+ }
+ for (const [path, value] of Object.entries(patch)) if (path !== variantPath) ordered[path] = value;
+ return { patch: ordered, reverted, skipped };
+ }
+ return { patch, reverted, skipped };
+}
+
+/** A short label for the turn, used in its Undo button. */
+export function editorAssistShortLabel(text: string, limit = 60): string {
+ return clipped(text, limit);
+}
diff --git a/application/v2_ui/src/lib/workspaceAgentAuthoring.ts b/application/v2_ui/src/lib/workspaceAgentAuthoring.ts
index 29088dc28..a1abdacaf 100644
--- a/application/v2_ui/src/lib/workspaceAgentAuthoring.ts
+++ b/application/v2_ui/src/lib/workspaceAgentAuthoring.ts
@@ -2,6 +2,8 @@
// Agent-only draft rules, shared by the routed editor and executable functional tests.
import type { WorkspaceModelEndpoint } from './types';
+import type { ModelCatalogEntry } from './models';
+import { getModelSupportedLevels, type ReasoningEffort } from './reasoning';
import {
EDITOR_SECRET_MASK,
editorName,
@@ -275,6 +277,18 @@ export function selectedAgentModel(draft: AgentConfiguration, choices: AgentMode
return choices.find((choice) => !choice.endpointId && choice.deployment === deployment);
}
+/** The reasoning levels the agent's selected model supports, from the bootstrap model catalogue. */
+export function agentReasoningLevels(
+ draft: AgentConfiguration, options: AgentEditorOptions, models: readonly ModelCatalogEntry[] | undefined,
+): ReasoningEffort[] {
+ const selectedModel = selectedAgentModel(draft, agentModelChoices(options));
+ const catalogModel = models?.find((model) => draft.model_endpoint_id
+ ? model.endpoint_id === draft.model_endpoint_id && model.model_id === draft.model_id
+ : model.model_name === selectedModel?.modelName &&
+ model.deployment_name === (draft.azure_openai_gpt_deployment || draft.azure_agent_apim_gpt_deployment));
+ return getModelSupportedLevels(catalogModel?.reasoning_capabilities);
+}
+
export function selectAgentModel(draft: AgentConfiguration, choice: AgentModelChoice): AgentConfiguration {
return {
...draft,
diff --git a/application/v2_ui/src/lib/workspaceEditorDrafts.ts b/application/v2_ui/src/lib/workspaceEditorDrafts.ts
index 8b24aefac..91ad785ae 100644
--- a/application/v2_ui/src/lib/workspaceEditorDrafts.ts
+++ b/application/v2_ui/src/lib/workspaceEditorDrafts.ts
@@ -49,6 +49,8 @@ interface DraftState {
const drafts = new Map>();
const createdActions = new Map();
+// The drafted-action placeholder a seeded new-action editor will replace once it saves.
+const actionHandoffs = new Map();
let draftOwner = '';
function warnBeforeDraftUnload(event: BeforeUnloadEvent): void {
@@ -70,6 +72,7 @@ function ownerKey(): string {
if (owner !== draftOwner) {
drafts.clear();
createdActions.clear();
+ actionHandoffs.clear();
draftOwner = owner;
syncDraftUnloadProtection();
}
@@ -79,6 +82,7 @@ function ownerKey(): string {
export function clearWorkspaceEditorDrafts(): void {
drafts.clear();
createdActions.clear();
+ actionHandoffs.clear();
syncDraftUnloadProtection();
}
@@ -154,6 +158,40 @@ export function queueCreatedWorkspaceAction(
syncDraftUnloadProtection();
}
+/**
+ * Opens the new-action editor that returns to ``returnPath`` with ``draft`` already filled in,
+ * such as an action Ask AI drafted for an agent. ``baseline`` is the editor's empty action, so
+ * the seeded draft counts as unsaved. The key matches the one the action editor builds.
+ * ``handoff`` is the agent's placeholder reference the saved action replaces; it is recorded here
+ * rather than in the agent draft because the agent editor unmounts as it navigates away.
+ */
+export function seedNewWorkspaceActionDraft(
+ returnPath: string,
+ draft: ActionConfiguration,
+ baseline: ActionConfiguration,
+ workspaceScope: EditorWorkspaceScope = PERSONAL_EDITOR_SCOPE,
+ handoff = '',
+): string {
+ const path = agentEditorReturnPath(returnPath, returnPathScope(workspaceScope));
+ if (!path) throw new Error('Invalid agent editor return path.');
+ const key = JSON.stringify(['personal', 'new', path]);
+ drafts.set(JSON.stringify([ownerKey(), ...editorScopeSegments(workspaceScope), 'actions', key]),
+ { draft: structuredClone(draft), baseline, original: null });
+ const handoffKey = JSON.stringify([ownerKey(), ...editorScopeSegments(workspaceScope), path]);
+ if (handoff) actionHandoffs.set(handoffKey, handoff);
+ else actionHandoffs.delete(handoffKey);
+ syncDraftUnloadProtection();
+ return path;
+}
+
+/** Forgets a drafted-action handoff, as when the person starts an unrelated new action. */
+export function clearWorkspaceActionHandoff(
+ returnPath: string,
+ workspaceScope: EditorWorkspaceScope = PERSONAL_EDITOR_SCOPE,
+): void {
+ actionHandoffs.delete(JSON.stringify([ownerKey(), ...editorScopeSegments(workspaceScope), returnPath]));
+}
+
export function takeCreatedWorkspaceAction(
returnPath: string,
workspaceScope: EditorWorkspaceScope = PERSONAL_EDITOR_SCOPE,
@@ -164,3 +202,14 @@ export function takeCreatedWorkspaceAction(
syncDraftUnloadProtection();
return action ?? null;
}
+
+/** Takes, once, the drafted-action placeholder a just-created action should replace. */
+export function takeWorkspaceActionHandoff(
+ returnPath: string,
+ workspaceScope: EditorWorkspaceScope = PERSONAL_EDITOR_SCOPE,
+): string {
+ const key = JSON.stringify([ownerKey(), ...editorScopeSegments(workspaceScope), returnPath]);
+ const handoff = actionHandoffs.get(key) ?? '';
+ actionHandoffs.delete(key);
+ return handoff;
+}
diff --git a/application/v2_ui/src/pages/workspace/ActionEditorPage.tsx b/application/v2_ui/src/pages/workspace/ActionEditorPage.tsx
index d53b25e69..9eb8fef0a 100644
--- a/application/v2_ui/src/pages/workspace/ActionEditorPage.tsx
+++ b/application/v2_ui/src/pages/workspace/ActionEditorPage.tsx
@@ -34,6 +34,9 @@ import {
import type { ActionConnectorProps, ActionIdentity } from '../../lib/workspaceActionTypes';
import { isGroupActionTestScope } from '../../lib/workspaceActionTypes';
import { useBootstrapStore } from '../../stores/bootstrapStore';
+import { actionAssistValues, applyActionAssistPatch, buildActionAssistView, type ActionAssistContext } from '../../lib/actionEditorAssist';
+import { useEditorAssist } from '../../components/editorAssist/useEditorAssist';
+import { EditorAskAiPanel, EditorAskAiToggle, EditorAssistLockBanner } from '../../components/editorAssist/EditorAskAiPanel';
export function ActionEditorPage({ adapter = PERSONAL_ACTION_WORKBENCH }: { adapter?: ActionWorkbenchAdapter }) {
const { resourceId = 'new' } = useParams();
@@ -193,6 +196,29 @@ function ActionEditor({ resourceId, scope, returnTo, adapter }: { resourceId: st
groupScope: isGroupActionTestScope(adapter.testScope) ? adapter.testScope : undefined,
globalScope: isGlobal,
};
+ const assistEnabled = useBootstrapStore((state) => state.data?.features?.enable_action_ai_assistant === true);
+ const [assistOpen, setAssistOpen] = useState(false);
+ const [jumpTo, setJumpTo] = useState<{ section: string; sequence: number } | null>(null);
+ const assistContext: ActionAssistContext = { catalogue, isNew, secretPaths: original?.secret_paths ?? [] };
+ const assist = useEditorAssist({
+ kind: 'action',
+ available: assistEnabled && !readOnly && canAuthor && !loading && !loadError && !catalogueLoading && !catalogueError,
+ scope: adapter.scope.kind === 'group' ? { kind: 'group', id: adapter.scope.id } : { kind: adapter.scope.kind },
+ recordKey: `${scope}:${resourceId}`,
+ blocked: saving || validating,
+ buildView: () => buildActionAssistView(draftRef.current, assistContext),
+ apply: (patch) => {
+ const next = applyActionAssistPatch(draftRef.current, patch, assistContext);
+ if (!next) return null;
+ validationController.current?.abort();
+ draftRef.current = next;
+ setDraft(next);
+ setFieldErrors({}); setError(null); setValidationFeedback(null);
+ return actionAssistValues(next, assistContext);
+ },
+ onJump: (section) => setJumpTo((current) => ({ section, sequence: (current?.sequence ?? 0) + 1 })),
+ });
+ const assistShown = assist.available && assistOpen;
const validateLocally = () => {
const errors = validateActionDraft(actionForSave(draft), definition, original);
@@ -360,7 +386,14 @@ function ActionEditor({ resourceId, scope, returnTo, adapter }: { resourceId: st
}))} dirty={dirty || Object.keys(validity).some((key) => key.startsWith('json:'))} saving={saving} readOnly={readOnly}
error={loadError || error} saveLabel="Save action" onSave={() => void save()} onDiscard={clear}
saveDisabled={!canAuthor || catalogueLoading || Boolean(catalogueError) || Boolean(loadError) || !definition}
+ sidePanel={assist.available ? setAssistOpen(false)} /> : undefined}
+ sidePanelOpen={assistShown} aiChangedSections={assist.changedSections}
+ locked={Boolean(assist.pending)} jumpTo={jumpTo}
+ lockBanner={assist.pending ? : undefined}
actions={!readOnly ? <>
+ {assist.available ? setAssistOpen((value) => !value)} /> : null}
{!isNew ? setReloadConfirmation(true)}> Load saved version : null}
void validate()}> {validating ? 'Validating…' : 'Validate configuration'}
diff --git a/application/v2_ui/src/pages/workspace/AgentEditorPage.tsx b/application/v2_ui/src/pages/workspace/AgentEditorPage.tsx
index 0555d54ab..5ec3ce1d8 100644
--- a/application/v2_ui/src/pages/workspace/AgentEditorPage.tsx
+++ b/application/v2_ui/src/pages/workspace/AgentEditorPage.tsx
@@ -19,12 +19,22 @@ import { chatHrefForAgent } from '../../lib/conversationUrl';
import { type AgentTargetCatalog } from '../../lib/agentDelegation';
import { PERSONAL_AGENT_WORKBENCH, type AgentWorkbenchAdapter } from '../../lib/agentWorkbench';
import {
- isRecord, type ActionConfiguration, type AgentConfiguration, type AgentEditorOptions,
+ isRecord, type ActionConfiguration, type ActionTypeDefinition, type AgentConfiguration, type AgentEditorOptions,
} from '../../lib/workspaceAuthoring';
-import { takeCreatedWorkspaceAction, useWorkspaceEditorDraft } from '../../lib/workspaceEditorDrafts';
-import { agentForSave, agentText, agentValidationErrors, applySafeAgentDraft, isAgentEditorEnvelope, newAgentDraft } from '../../lib/workspaceAgentAuthoring';
+import {
+ clearWorkspaceActionHandoff, seedNewWorkspaceActionDraft, takeCreatedWorkspaceAction, takeWorkspaceActionHandoff, useWorkspaceEditorDraft,
+} from '../../lib/workspaceEditorDrafts';
+import { agentForSave, agentReasoningLevels, agentText, agentValidationErrors, applySafeAgentDraft, isAgentEditorEnvelope, newAgentDraft } from '../../lib/workspaceAgentAuthoring';
import { newAgentActionErrors } from '../../lib/workspaceAgentActions';
import { agentKnowledgeErrors, type AgentKnowledgeCatalog } from '../../lib/workspaceAgentKnowledge';
+import {
+ agentAssistValues, applyAgentAssistPatch, buildAgentAssistView, newPendingActionReference, pendingAgentActions,
+ prunePendingAgentActions, resolvePendingAgentAction, type AgentAssistContext,
+} from '../../lib/agentEditorAssist';
+import { actionForSave, createActionDraft, validateActionDraft } from '../../lib/workspaceActionLogic';
+import type { ModelCatalogEntry } from '../../lib/models';
+import { useEditorAssist } from '../../components/editorAssist/useEditorAssist';
+import { EditorAskAiPanel, EditorAskAiToggle, EditorAssistLockBanner } from '../../components/editorAssist/EditorAskAiPanel';
import { useBootstrapStore } from '../../stores/bootstrapStore';
function message(cause: unknown): string {
@@ -42,7 +52,7 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
const { draft, setDraft: setStoredDraft, original, load, clear, dirty, restored } =
useWorkspaceEditorDraft('agents', `${scope}:${resourceId}`, newAgentDraft, adapter.draftScope);
const setDraft: Dispatch> = (update) => setStoredDraft((current) =>
- applySafeAgentDraft(current, typeof update === 'function' ? update(current) : update, original));
+ applySafeAgentDraft(current, prunePendingAgentActions(typeof update === 'function' ? update(current) : update), original));
const [options, setOptions] = useState(null);
const [bootLoading, setBootLoading] = useState(true);
const [bootError, setBootError] = useState(null);
@@ -62,6 +72,9 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
const [knowledgeLoading, setKnowledgeLoading] = useState(false);
const [knowledgeError, setKnowledgeError] = useState(null);
const [knowledgeRevision, setKnowledgeRevision] = useState(0);
+ const [actionTypes, setActionTypes] = useState(null);
+ // Drafted actions an undo may bring back after a later turn or Remove dropped them.
+ const pendingStash = useRef(new Map());
const returnedAction = useRef(null);
const handoffChecked = useRef(false);
// A member without the edit hint sees the group editor read-only; personal scope always authors.
@@ -69,6 +82,47 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
const readOnly = accessReadOnly || original?.read_only === true || !canAuthor;
const advancedError = agentAdvancedError(draft, original);
const arraySecretError = agentText(draft._editor_array_secret_error) || null;
+ const assistEnabled = useBootstrapStore((state) => state.data?.features?.enable_agent_ai_assistant === true);
+ const models = useBootstrapStore((state) => state.data?.catalogs?.models) as ModelCatalogEntry[] | undefined;
+ const [assistOpen, setAssistOpen] = useState(false);
+ const [jumpTo, setJumpTo] = useState<{ section: string; sequence: number } | null>(null);
+ // Ask AI reads and changes the latest draft between renders, so it works from a ref.
+ const draftRef = useRef(draft);
+ draftRef.current = draft;
+ const actionWorkbench = adapter.actionWorkbench;
+ const assistContext = (current: AgentConfiguration): AgentAssistContext | null => options ? {
+ options, actions, targets, knowledge, knowledgeScopes: adapter.knowledgeScopes,
+ reasoningLevels: agentReasoningLevels(current, options, models), ownerId, isNew,
+ ...(actionTypes && canCreateActions && actionWorkbench ? {
+ actionTypes,
+ newPendingReference: newPendingActionReference,
+ recallPendingAction: (reference: string) => pendingStash.current.get(reference),
+ } : {}),
+ } : null;
+ const assist = useEditorAssist({
+ kind: 'agent',
+ available: assistEnabled && !readOnly && !bootLoading && !bootError && Boolean(options),
+ scope: adapter.scope.kind === 'group' ? { kind: 'group', id: adapter.scope.id } : { kind: adapter.scope.kind },
+ recordKey: `${scope}:${resourceId}`,
+ blocked: saving || iconBusy || Boolean(advancedError),
+ buildView: () => {
+ const context = assistContext(draftRef.current);
+ if (!context) throw new Error('The agent editor is still loading.');
+ return buildAgentAssistView(draftRef.current, context);
+ },
+ apply: (patch, newItems) => {
+ const current = draftRef.current;
+ const context = assistContext(current);
+ const next = context ? applyAgentAssistPatch(current, patch, context, newItems) : null;
+ if (!next || !context) return null;
+ for (const { reference, action } of pendingAgentActions(next)) pendingStash.current.set(reference, action);
+ draftRef.current = next;
+ setDraft(next);
+ return agentAssistValues(next, { ...context, reasoningLevels: agentReasoningLevels(next, context.options, models) });
+ },
+ onJump: (section) => setJumpTo((current) => ({ section, sequence: (current?.sequence ?? 0) + 1 })),
+ });
+ const assistShown = assist.available && assistOpen;
useEffect(() => {
const controller = new AbortController();
@@ -103,12 +157,17 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
handoffChecked.current = true;
const action = takeCreatedWorkspaceAction(location.pathname, adapter.draftScope);
if (!action) return;
+ const handoff = takeWorkspaceActionHandoff(location.pathname, adapter.draftScope);
returnedAction.current = action;
setActions((current) => [...current.filter((item) => item.id !== action.id || item.is_global !== action.is_global), action]);
- setDraft((current) => ({
- ...current,
- actions_to_load: current.actions_to_load.includes(action.id) ? current.actions_to_load : [...current.actions_to_load, action.id],
- }));
+ setDraft((current) => {
+ // An action Ask AI drafted and the person finished in the action editor replaces its placeholder.
+ if (handoff && current.actions_to_load.includes(handoff)) return resolvePendingAgentAction(current, handoff, action.id);
+ return {
+ ...current,
+ actions_to_load: current.actions_to_load.includes(action.id) ? current.actions_to_load : [...current.actions_to_load, action.id],
+ };
+ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bootLoading, bootError, readOnly, location.pathname]);
@@ -138,6 +197,16 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bootLoading, bootError, actionsRevision]);
+ useEffect(() => {
+ if (!assistEnabled || readOnly || bootLoading || bootError || !canCreateActions || !actionWorkbench) return;
+ const controller = new AbortController();
+ // Without the catalogue Ask AI only assigns existing actions, so a failure stays quiet.
+ void actionWorkbench.fetchTypes(controller.signal).then((types) => {
+ if (!controller.signal.aborted && Array.isArray(types)) setActionTypes(types);
+ }).catch(() => { if (!controller.signal.aborted) setActionTypes(null); });
+ return () => controller.abort();
+ }, [assistEnabled, readOnly, bootLoading, bootError, canCreateActions, actionWorkbench]);
+
useEffect(() => {
if (bootLoading || bootError || draft.agent_type !== 'local') return;
const controller = new AbortController();
@@ -152,13 +221,32 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bootLoading, bootError, draft.agent_type, knowledgeRevision]);
+ const pendingActions = pendingAgentActions(draft);
+ const actionTypeFor = (action: ActionConfiguration) => actionTypes?.find((type) => type.type === action.type);
+ const pendingIssues = Object.fromEntries(pendingActions.map(({ reference, action }) => [
+ reference, [...new Set(Object.values(validateActionDraft(actionForSave(action), actionTypeFor(action))))],
+ ]));
+
+ const finishPendingAction = (reference: string) => {
+ const pending = pendingActions.find((item) => item.reference === reference);
+ if (!pending || !actionWorkbench) return;
+ seedNewWorkspaceActionDraft(location.pathname, pending.action, createActionDraft(), actionWorkbench.draftScope, reference);
+ navigate(`${adapter.actionsBasePath}/new?returnTo=${encodeURIComponent(location.pathname)}`,
+ { state: { preserveWorkspaceDraft: true, workspaceEditorFrom: location.key } });
+ };
+
const save = async () => {
if (!options || readOnly || saving || iconBusy) return;
+ const pendingErrors = pendingActions.flatMap(({ reference, action }) => (pendingIssues[reference] ?? []).length
+ ? [`New action ${agentText(action.displayName) || action.name} needs attention: ${pendingIssues[reference].join(' ')} Finish it in the action editor or remove it.`]
+ : []);
+ if (pendingActions.length && (!actionWorkbench || !canCreateActions)) pendingErrors.push('New actions from Ask AI can’t be created here. Remove them before saving.');
const errors = [
...agentValidationErrors(draft, options),
...agentKnowledgeErrors(draft),
...newAgentActionErrors(draft, original?.record ?? null, actions, targets, ownerId),
...(advancedError ? [advancedError] : []),
+ ...pendingErrors,
];
if (errors.length) {
setHasSaveConflict(false);
@@ -168,8 +256,26 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
setSaving(true);
setSaveError(null);
setHasSaveConflict(false);
+ let working = draft;
try {
- const resource = await adapter.save(agentForSave(draft), original);
+ // Create the actions Ask AI drafted first, so the agent saves with their real IDs.
+ for (const { reference, action } of pendingActions) {
+ if (!actionWorkbench) break;
+ let created: ActionConfiguration | undefined;
+ try {
+ created = (await actionWorkbench.save(actionForSave(action), null)).record;
+ } catch (cause) {
+ throw new Error(`New action ${agentText(action.displayName) || action.name} could not be created: ${message(cause)} The agent was not saved.`);
+ }
+ if (!created?.id) throw new Error('An action save response did not include an identifier. The agent was not saved.');
+ const saved = created;
+ working = resolvePendingAgentAction(working, reference, saved.id);
+ pendingStash.current.delete(reference);
+ setActions((current) => [...current.filter((item) => item.id !== saved.id), saved]);
+ setDraft(working);
+ }
+ const { _pendingActions: _drafts, ...agent } = working;
+ const resource = await adapter.save(agentForSave(agent as AgentConfiguration), original);
if (!resource.record?.id) throw new Error('The save response did not include an agent identifier. Reload before trying again.');
load(resource);
clear();
@@ -206,7 +312,14 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
backTo={adapter.basePath} dirty={dirty || iconBusy} saving={saving} readOnly={readOnly} error={arraySecretError || saveError}
onSave={() => void save()} onDiscard={clear} saveLabel="Save agent" saveDisabled={Boolean(advancedError) || iconBusy}
initialSection={new URLSearchParams(location.search).get('templates') === '1' ? 'templates' : undefined}
+ sidePanel={assist.available ? setAssistOpen(false)} /> : undefined}
+ sidePanelOpen={assistShown} aiChangedSections={assist.changedSections}
+ locked={Boolean(assist.pending)} jumpTo={jumpTo}
+ lockBanner={assist.pending ? : undefined}
actions={<>
+ {assist.available ? setAssistOpen((value) => !value)} /> : null}
{advancedError ?
{arraySecretError ? 'Review stored array credentials before saving.' : 'Fix Additional settings JSON to enable saving.'}
: null}
@@ -235,8 +348,13 @@ function AgentEditorSession({ resourceId, scope, adapter }: { resourceId: string
actions={actions} targets={targets} loading={actionsLoading} error={actionsError} targetError={targetError}
builtinActions={options.builtin_actions} ownerId={ownerId} canCreateActions={canCreateActions} readOnly={readOnly}
scopeKind={adapter.scope.kind}
+ pendingActions={pendingActions} pendingIssues={pendingIssues}
+ onFinishPendingAction={actionWorkbench ? finishPendingAction : undefined}
onRefresh={() => setActionsRevision((value) => value + 1)}
- onNewAction={() => navigate(`${adapter.actionsBasePath}/new?returnTo=${encodeURIComponent(location.pathname)}`, { state: { preserveWorkspaceDraft: true, workspaceEditorFrom: location.key } })} />) },
+ onNewAction={() => {
+ clearWorkspaceActionHandoff(location.pathname, adapter.draftScope);
+ navigate(`${adapter.actionsBasePath}/new?returnTo=${encodeURIComponent(location.pathname)}`, { state: { preserveWorkspaceDraft: true, workspaceEditorFrom: location.key } });
+ }} />) },
{ id: 'knowledge', label: 'Assigned knowledge', icon: BookOpen,
description: 'The documents, tags and web pages the agent answers from.', content: structured(>;
+ /** Submission ids, oldest first. */
+ order: readonly string[];
+ /** Epoch milliseconds before which the assistant asked not to be sent another request. */
+ retryUntil: number;
+ record: (turnId: string, record: EditorAssistTurnRecord) => void;
+ update: (turnId: string, change: (record: EditorAssistTurnRecord) => EditorAssistTurnRecord) => void;
+ waitUntil: (time: number) => void;
+ /** Forget everything, for tests and sign-out. */
+ reset: () => void;
+}
+
+export const useEditorAssistStore = create((set) => ({
+ turns: {},
+ order: [],
+ retryUntil: 0,
+
+ record: (turnId, record) => set((state) => {
+ const order = [...state.order.filter((id) => id !== turnId), turnId];
+ const turns: Record = { ...state.turns, [turnId]: record };
+ while (order.length > MAX_EDITOR_ASSIST_TURNS) {
+ const dropped = order.shift();
+ if (dropped !== undefined) delete turns[dropped];
+ }
+ return { turns, order };
+ }),
+
+ update: (turnId, change) => set((state) => {
+ const current = state.turns[turnId];
+ if (!current) return {};
+ const next = change(current);
+ return next === current ? {} : { turns: { ...state.turns, [turnId]: next } };
+ }),
+
+ waitUntil: (time) => set((state) => (time > state.retryUntil ? { retryUntil: time } : {})),
+
+ reset: () => set({ turns: {}, order: [], retryUntil: 0 }),
+}));
diff --git a/docs/_data/app_surface.yml b/docs/_data/app_surface.yml
index bdf0498ab..0657cfa5c 100644
--- a/docs/_data/app_surface.yml
+++ b/docs/_data/app_surface.yml
@@ -1,7 +1,7 @@
# Generated by scripts\build_docs_inventory.py -- do not edit by hand.
# Regenerate with: python scripts\build_docs_inventory.py
counts:
- capabilities: 126
+ capabilities: 128
admin_groups: 15
admin_tabs: 49
admin_sections: 107
@@ -11,6 +11,10 @@ counts:
app_pages: 29
feature_surfaces: 25
capabilities:
+- key: enable_action_ai_assistant
+ default: true
+- key: enable_agent_ai_assistant
+ default: true
- key: enable_agent_template_gallery
default: true
- key: enable_ai_notice
diff --git a/docs/_data/features.yml b/docs/_data/features.yml
index 863629406..8ac28ca52 100644
--- a/docs/_data/features.yml
+++ b/docs/_data/features.yml
@@ -442,6 +442,25 @@ workflow-ai-assistant:
reference:
- title: "Workflow settings"
url: /admin/workflow/
+agent-action-ai-assistant:
+ name: "Ask AI in the agent and action editors"
+ area: "Agents and actions"
+ audience: "End users"
+ summary: "Adds an Ask AI panel to the V2 agent and action editors for personal, group and global agents and actions. A person describes what they want, and the assistant fills in the unsaved draft, such as instructions, model, assigned actions and knowledge for an agent, or the type, configuration and sign-in method for an action. Each change is highlighted and can be undone by turn. The agent assistant can also draft new actions for the agent, which are created only when the agent is saved."
+ why: "Use this when people know what an agent or connector should do but not which fields to set, for example \"build a helpdesk agent that searches our IT policies and can look up tickets\". Nothing is saved until the person saves, the usual checks still run, and the assistant never sees or enters keys, secrets or other credentials."
+ default_state: "Enabled"
+ admin_tab: agents-actions
+ setting_keys:
+ - enable_agent_ai_assistant
+ - enable_action_ai_assistant
+ guides:
+ - title: "Create an agent"
+ url: /guides/create-an-agent/
+ - title: "Create an action"
+ url: /guides/create-an-action/
+ reference:
+ - title: "Agents and actions settings"
+ url: /admin/agents-actions/
workflow-results-in-chat:
name: "Workflow results in chat"
area: "Chat and retrieval"
diff --git a/docs/admin/agents-actions.md b/docs/admin/agents-actions.md
index 6c1164062..88ac7a358 100644
--- a/docs/admin/agents-actions.md
+++ b/docs/admin/agents-actions.md
@@ -88,11 +88,26 @@ Global Actions**, with a **Review** link to each under
Those switches decide who may use the agents and actions you publish, and they
keep their value while Enable Agents is off.
+**Enable Ask AI in the Agent Editor** and **Enable Ask AI in the Action Editor**
+add an **Ask AI** panel to the V2 agent and action editors, for personal, group
+and global agents and actions alike. People describe what they want, and the
+assistant fills in the unsaved draft with highlighted changes they can undo. It
+never saves, never sees or enters keys or other credentials, and only offers
+models, actions and knowledge the person could already pick in the editor. In
+the agent editor it can also draft up to three new actions, which are created
+only when the agent is saved. Both are on by default and only take effect while
+Enable Agents is on. Turn one off if you would rather people configure that
+editor entirely by hand, or to stop its model calls. The panel uses the
+deployment's instruction-drafting model and shares the workflow assistant's
+per-user rate limit.
+
#### Settings
| Setting | What it does | Default | Notes |
| --- | --- | --- | --- |
| Enable Agents | Starts the agent runtime. Gates the Agents catalog page, the global agent and action tables, and every workspace permission in this group. | Off | `enable_semantic_kernel`; capability toggle |
+| Enable Ask AI in the Agent Editor | Shows the Ask AI panel in the V2 agent editor, which fills in an agent's draft from a plain-language request and can draft new actions for it. | On | `enable_agent_ai_assistant`; requires Enable Agents |
+| Enable Ask AI in the Action Editor | Shows the Ask AI panel in the V2 action editor, which fills in an action's type, configuration and sign-in method from a plain-language request. | On | `enable_action_ai_assistant`; requires Enable Agents |
| Workspace Mode | Chooses between one shared global set of agents and actions, and a separate collection per user and group. | Off | `per_user_semantic_kernel` |
| Add Global Agents and Actions to Workspaces | Includes the global set in every workspace collection, so people see both what they built and what you publish. | Off | `merge_global_semantic_kernel_with_workspace`; only applies in Workspace Mode |
| Orchestration Type | Selects how a chat is routed across agents. Hidden while only one mode is available. | default_agent | `orchestration_type`; saved by the orchestration settings API |
diff --git a/docs/explanation/features/AGENT_ACTION_AI_ASSISTANT.md b/docs/explanation/features/AGENT_ACTION_AI_ASSISTANT.md
new file mode 100644
index 000000000..9f5ae4d6f
--- /dev/null
+++ b/docs/explanation/features/AGENT_ACTION_AI_ASSISTANT.md
@@ -0,0 +1,127 @@
+# Ask AI in the Agent and Action Editors (v0.261.288)
+
+## Overview
+
+The V2 agent and action editors have an **Ask AI** panel that works like the workflow editor's.
+A person describes what they want in plain language, for example "a helpdesk agent that answers
+from our IT policy documents and can look up tickets", and the assistant fills in the unsaved
+draft. Every changed field is highlighted, each turn lists its changes with **Jump to**, and
+**Undo this change** takes a whole turn back. The assistant never saves, never sees or enters keys,
+secrets or other credentials, and only offers what the person could choose in the editor.
+
+In the agent editor the assistant can also draft up to three new actions for the agent. They are
+created in the agent's workspace only when the agent is saved.
+
+Implemented in version: **0.261.288**, tracked in `application/single_app/config.py`. It was
+built in phases (shared backend, agent editor, action editor, drafted actions, then tests,
+documentation and the drafted-action hand-off fix) and ships as one release.
+
+Dependencies:
+
+- **Enable Agents** (`enable_semantic_kernel`) must be on.
+- The deployment's instruction-drafting model deployment, the same one **Draft with AI** uses.
+- The [workflow AI assistant](WORKFLOW_AI_ASSISTANT.md) rate limiter, which these editors share.
+
+## Technical Specifications
+
+### Architecture
+
+The feature is one shared framework with two editor adapters.
+
+- **Backend**: `functions_editor_assist.py` validates the request, builds the prompt from a
+ *view* of the draft (its sections and fields, each with a kind, limits and allowed options),
+ calls the model, and checks the answer. The model may only return values for fields the view
+ described, within their limits and option lists, plus optional new items. The endpoint never
+ receives or returns a whole record and writes nothing. `functions_editor_assist_runtime.py`
+ runs the model call with a deadline, one correction retry and the shared rate limiter.
+- **Frontend framework**: `lib/editorAssist.ts` defines the view, patch and change contract.
+ `components/editorAssist/useEditorAssist.ts` runs turns, checks that the answer still matches
+ the draft it was sent with, applies it, and records per-turn undo in `stores/editorAssistStore.ts`.
+ `components/editorAssist/EditorAskAiPanel.tsx` is the side panel and header toggle, shown through
+ `WorkspaceEditorFrame.tsx`'s side-panel slot.
+- **Agent adapter**: `lib/agentEditorAssist.ts` describes and applies display name, description,
+ instructions, model, assigned actions, completion token limit, reasoning effort and knowledge
+ (whether to use it, workspaces, specific documents, tags and web pages). Agent type is shown
+ read-only.
+- **Action adapter**: `lib/actionEditorAssist.ts` describes and applies name, description, action
+ type and, for the chosen type, its configuration fields, capabilities and sign-in method. Fields
+ that hold secrets are never described, so their values never leave the browser.
+
+### API endpoints
+
+| Endpoint | Purpose |
+| --- | --- |
+| `POST /api/agents/assist` | Runs one Ask AI turn for an agent draft. |
+| `POST /api/actions/assist` | Runs one Ask AI turn for an action draft. |
+
+Both take `scope` (`personal`, `group` or `global`), `group_id` for group scope, the instruction,
+recent turns and the draft view, and return a reply, a patch of changed values, warnings and, for
+agents, any new items. They authorize by scope:
+
+- **Global**: the signed-in user must be an administrator.
+- **Group**: the user must hold a group role that may edit agents or actions, and the group must
+ be available.
+- **Personal**: `allow_user_agents` or `allow_user_plugins` must be on.
+
+When the editor's toggle is off they return `agent_assistant_disabled` or
+`action_assistant_disabled`. Request limits include a 2,000-character instruction, 20 earlier
+turns, 300 fields and 80 operations per answer.
+
+### Configuration
+
+| Setting | Default | Effect |
+| --- | --- | --- |
+| `enable_agent_ai_assistant` | On | Shows Ask AI in the agent editor and allows `POST /api/agents/assist`. |
+| `enable_action_ai_assistant` | On | Shows Ask AI in the action editor and allows `POST /api/actions/assist`. |
+
+Both are in **Admin Settings > Agents and Actions** and take effect only while Enable Agents is on.
+The bootstrap exposes them to the V2 app as `features.enable_agent_ai_assistant` and
+`features.enable_action_ai_assistant`.
+
+### Drafted actions
+
+When no existing action fits, the agent assistant may return up to three new actions. Each is held
+in the agent draft under a placeholder reference (`new:N1` and so on) and listed under **New
+actions from Ask AI** in the Actions section, with any validation issues shown as **Needs attention
+before saving**. Call agent actions are never drafted.
+
+- **Save agent** first creates each drafted action in the agent's workspace, then saves the agent
+ with the placeholders replaced by the new action IDs. A drafted action with validation issues
+ blocks the save before anything is written.
+- **Finish in action editor** opens the new-action editor already filled in with the drafted
+ action. Saving it returns to the agent and replaces that placeholder with the new action. The
+ hand-off is recorded in the same in-memory draft cache as the created-action hand-off, scoped to
+ the owner and workspace.
+- **Remove** drops a drafted action. Undoing the turn that drafted it does the same, and undoing a
+ later turn that removed it brings it back.
+
+## Usage Instructions
+
+1. Open an agent or action in the V2 editor, for a personal, group or global agent or action.
+2. Select **Ask AI** in the editor's header and describe what you want.
+3. Review the highlighted fields. Use **Jump to** to see a change in its section, or **Undo this
+ change** to take the turn back.
+4. Enter any credentials yourself, then save as usual.
+
+The guides [Create an agent](../../guides/create-an-agent.md) and
+[Create an action](../../guides/create-an-action.md) walk through this for people building agents.
+
+## Testing and Validation
+
+- `functional_tests/test_editor_assist_core.py`: request validation, prompt building and answer checking.
+- `functional_tests/route_tests/test_editor_assist_policy.py`: scope authorization, feature toggles and route policy.
+- `functional_tests/test_v2_agent_editor_assist_logic.mjs`, `test_v2_action_editor_assist_logic.mjs`
+ and `test_v2_agent_drafted_actions_logic.mjs`: the adapters' describe and apply logic.
+- `ui_tests/test_v2_agent_action_ask_ai.py`: an agent turn applied and undone, a drafted action
+ finished in the action editor and saved with the agent, an action turn that never sends the
+ stored key, and the panels hidden while the toggles are off.
+- `ui_tests/test_v2_group_action_drafts.ts` (run by `test_v2_group_actions.py`): the drafted-action
+ hand-off never crosses a workspace boundary.
+
+### Known limitations
+
+- The assistant can't set the endpoint for action types without a native definition, other than
+ OpenAPI and MCP. A drafted action of such a type shows "Endpoint is required" and must be
+ finished in the action editor.
+- It never enters credentials, uploads or fetches OpenAPI specifications, or saves.
+- Drafted actions live only in the browser tab until the agent is saved.
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index ff76861cb..7e69e1ecc 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,17 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes).
+### **(v0.261.288)**
+
+#### New Features
+
+* **Ask AI in the Agent and Action Editors**
+ * The V2 agent and action editors for personal, group and global agents and actions now have an **Ask AI** panel, matching the workflow editor. Describe what you want and the assistant fills in the unsaved draft, highlighting each changed field, with **Jump to** and **Undo this change** for every turn. It never saves and never sees or enters keys or other credentials.
+ * In the agent editor it can set the name, description, instructions, model, assigned actions, token limit, reasoning effort and knowledge. In the action editor it can choose the action type and fill in its configuration, capabilities and sign-in method.
+ * The agent assistant can draft up to three new actions for the agent. They are listed under **New actions from Ask AI** and created only when the agent is saved. One that still needs something, such as an endpoint, blocks the save and can be completed with **Finish in action editor**, which returns to the agent with the action assigned.
+ * Administrators can turn each panel off with **Enable Ask AI in the Agent Editor** and **Enable Ask AI in the Action Editor** in Agents and Actions settings. Both are on by default.
+ * (Ref: `functions_editor_assist.py`, `POST /api/agents/assist`, `POST /api/actions/assist`, `EditorAskAiPanel.tsx`, `agentEditorAssist.ts`, `actionEditorAssist.ts`, `AgentActionPicker.tsx`, `workspaceEditorDrafts.ts`, [Ask AI in the Agent and Action Editors](features/AGENT_ACTION_AI_ASSISTANT.md))
+
### **(v0.261.287)**
#### New Features
diff --git a/docs/guides/create-an-action.md b/docs/guides/create-an-action.md
index de228cfe1..c268a97ae 100644
--- a/docs/guides/create-an-action.md
+++ b/docs/guides/create-an-action.md
@@ -34,6 +34,22 @@ See [Build agents and actions in My Workspace]({{ '/guides/workspace-agents-and-
for the native experience, implemented in **0.261.096** in
`application/single_app/config.py`.
+### Set up the action with Ask AI
+
+Select **Ask AI** in the editor's header and describe the connector, for example
+"an MCP server at
+https://example.com/mcp that only exposes the search tool". The assistant can
+choose the action type and fill in its name, description, configuration and
+sign-in method, highlighting each field it changed. Each turn lists its changes,
+with **Undo this change** to take the whole turn back.
+
+The assistant never sees or enters keys, secrets or other credentials; enter
+those yourself in **Authentication**. It also doesn't upload or fetch OpenAPI
+specifications. Nothing is saved until you choose **Save action**.
+
+Administrators can turn the panel off with **Enable Ask AI in the Action Editor**
+in [Agents settings]({{ '/admin/agents-actions/' | relative_url }}).
+
## Classic interface steps
1. Open **Personal Workspace** or the target **Group Workspace**.
diff --git a/docs/guides/create-an-agent.md b/docs/guides/create-an-agent.md
index d1a307a69..5bd6d7b66 100644
--- a/docs/guides/create-an-agent.md
+++ b/docs/guides/create-an-agent.md
@@ -34,6 +34,30 @@ without following a modal wizard. See [Build agents and actions in My Workspace]
for the native workflow, implemented in **0.261.096** in
`application/single_app/config.py`.
+### Build the agent with Ask AI
+
+Select **Ask AI** in the editor's header to open the assistant beside the form,
+then describe the agent in your own words, for example "a helpdesk agent that
+answers from our IT policy documents and can look up tickets". The assistant
+fills in the draft for you, such as the name, description, instructions, model,
+assigned actions and knowledge, and highlights every field it changed. Each turn
+lists its changes, with **Undo this change** to take the whole turn back.
+
+Nothing is saved until you choose **Save agent**, and the usual checks still
+run. The assistant only offers models, actions and knowledge you could pick
+yourself, and it never sees or enters keys or other credentials.
+
+When no existing action fits, the assistant can draft up to three new actions
+for the agent. They are listed under **New actions from Ask AI** in the Actions
+section and are created in the same workspace only when you save the agent. If
+a drafted action still needs something, such as an endpoint or credentials, it
+shows what is missing and blocks the save. Choose **Finish in action editor** to
+complete it; when you save the action you return to the agent with the new
+action assigned. **Remove** drops a drafted action you don't want.
+
+Administrators can turn the panel off with **Enable Ask AI in the Agent Editor**
+in [Agents settings]({{ '/admin/agents-actions/' | relative_url }}).
+
## Classic interface steps
1. Open **Personal Workspace** or the target **Group Workspace**.
diff --git a/functional_tests/route_tests/test_editor_assist_policy.py b/functional_tests/route_tests/test_editor_assist_policy.py
new file mode 100644
index 000000000..e2010da7a
--- /dev/null
+++ b/functional_tests/route_tests/test_editor_assist_policy.py
@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+# test_editor_assist_policy.py
+"""
+Functional policy tests for the agent and action editor Ask AI routes.
+Version: 0.261.278
+Implemented in: 0.261.278
+
+This test ensures that ``POST /api/agents/assist`` and ``POST /api/actions/assist`` keep their
+Blueprint, Swagger and authentication decorators, refuse before any service starts when the admin
+toggle or Semantic Kernel is off, authorize the requested scope (personal, group, global) with the
+same roles that may edit agents and actions there, and answer with uncached strict JSON that never
+echoes settings. The real route bodies run over the real editor assist runtime and core on a closed
+Flask app, with a scripted model and an in-memory limiter. No Azure service is used.
+"""
+
+import ast
+import json
+import sys
+from pathlib import Path
+
+import pytest
+from flask import Flask, session
+
+ROOT = Path(__file__).resolve().parents[2]
+APP = ROOT / "application" / "single_app"
+sys.path.insert(0, str(APP))
+sys.path.insert(0, str(ROOT / "functional_tests"))
+
+import functions_editor_assist as core # noqa: E402
+import functions_editor_assist_runtime as runtime # noqa: E402
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+
+
+AGENT_ROUTES = APP / "route_backend_agents.py"
+ACTION_ROUTES = APP / "route_backend_plugins.py"
+SETTINGS_MODULE = APP / "functions_settings.py"
+USER_ID = "user-0001"
+GROUP_ID = "group-0001"
+SECRET = "SECRET-SETTING-VALUE-5c1d"
+SUBMISSION_ID = "0f8fad5b-d9cb-469f-a165-70867728950e"
+ROUTES = {
+ "agent": (AGENT_ROUTES, "/api/agents/assist", "assist_agent_editor", "bpa"),
+ "action": (ACTION_ROUTES, "/api/actions/assist", "assist_action_editor", "bpap"),
+}
+
+
+def parsed(path):
+ return ast.parse(path.read_text(encoding="utf-8"))
+
+
+def route_function(kind):
+ path, route_path, _name, _bp = ROUTES[kind]
+ matches = [
+ node for node in parsed(path).body
+ if isinstance(node, ast.FunctionDef)
+ and node.decorator_list
+ and isinstance(node.decorator_list[0], ast.Call)
+ and node.decorator_list[0].args
+ and getattr(node.decorator_list[0].args[0], "value", None) == route_path
+ ]
+ assert len(matches) == 1, f"expected one route for {route_path}, found {len(matches)}"
+ return matches[0]
+
+
+def settings_functions(*names):
+ found = {
+ node.name: node for node in parsed(SETTINGS_MODULE).body
+ if isinstance(node, ast.FunctionDef) and node.name in names
+ }
+ assert sorted(found) == sorted(names)
+ namespace = {}
+ exec(compile(ast.Module(body=[found[name] for name in names], type_ignores=[]), str(SETTINGS_MODULE), "exec"), namespace)
+ return namespace
+
+
+class FakeBlueprint:
+ def __init__(self, app):
+ self.app = app
+
+ def route(self, path, methods):
+ def register(view):
+ self.app.add_url_rule(path, endpoint=view.__name__, view_func=view, methods=methods)
+ return view
+ return register
+
+
+def unchanged(view):
+ return view
+
+
+class FakeLimiter:
+ def __init__(self):
+ self.events = []
+
+ def acquire(self, user_id):
+ self.events.append(("acquire", user_id))
+ return "lease"
+
+ def release(self, lease, refund=False):
+ self.events.append(("release", refund))
+
+
+class ScriptedModel:
+ def __init__(self, reply):
+ self.reply = reply
+ self.calls = []
+
+ def __call__(self, messages, timeout):
+ self.calls.append(messages)
+ return json.dumps(self.reply), "stop"
+
+
+def agent_body(**extra):
+ body = {
+ "submission_id": SUBMISSION_ID,
+ "instruction": "Name it Helper.",
+ "fields": [{"path": "/display_name", "label": "Name", "kind": "text"}],
+ "values": {"/display_name": "Old"},
+ }
+ body.update(extra)
+ return body
+
+
+def action_body(**extra):
+ body = {
+ "submission_id": SUBMISSION_ID,
+ "instruction": "Name it Helper.",
+ "fields": [
+ {"path": "/displayName", "label": "Name", "kind": "text"},
+ {"path": "/additionalFields/api_key", "label": "API key", "kind": "text"},
+ ],
+ "values": {"/displayName": "Old", "/additionalFields/api_key": SECRET},
+ }
+ body.update(extra)
+ return body
+
+
+class Harness:
+ def __init__(self, kind, monkeypatch):
+ self.kind = kind
+ self.settings = {
+ "enable_semantic_kernel": True,
+ "allow_user_agents": True,
+ "allow_user_plugins": True,
+ "azure_openai_gpt_key": SECRET,
+ }
+ self.roles = []
+ self.group_available = (True, None)
+ self.group_error = None
+ self.group_calls = []
+ self.limiter = FakeLimiter()
+ path = "/display_name" if kind == "agent" else "/displayName"
+ self.model = ScriptedModel({"reply": "Renamed.", "operations": [{"op": "set", "path": path, "value": "Helper"}]})
+ self.built = []
+
+ def build_services(*, client_factory, limiter=None):
+ self.built.append(client_factory)
+ return core.EditorAssistServices(limiter=self.limiter, call_model=self.model)
+
+ monkeypatch.setattr(runtime, "build_editor_assist_services", build_services)
+
+ import functions_agent_delegation
+
+ def resolve_scope(user_id, group_id=None, *, allowed_roles):
+ self.group_calls.append((user_id, group_id, tuple(allowed_roles)))
+ if self.group_error is not None:
+ raise self.group_error
+ return group_id or GROUP_ID
+
+ monkeypatch.setattr(functions_agent_delegation, "resolve_delegation_group_scope", resolve_scope)
+
+ app = Flask(f"editor-assist-{kind}")
+ app.secret_key = "test"
+ _path, _route, _name, bp_name = ROUTES[kind]
+ gates = settings_functions("is_agent_assistant_enabled", "is_action_assistant_enabled")
+ namespace = {
+ bp_name: FakeBlueprint(app),
+ "swagger_route": lambda **_security: unchanged,
+ "get_auth_security": lambda: [{"bearerAuth": []}],
+ "login_required": unchanged,
+ "user_required": unchanged,
+ "session": session,
+ "get_settings": lambda: self.settings,
+ "get_current_user_id": lambda: USER_ID,
+ "is_agent_assistant_enabled": gates["is_agent_assistant_enabled"],
+ "is_action_assistant_enabled": gates["is_action_assistant_enabled"],
+ "EditorAssistError": core.EditorAssistError,
+ "editor_assist_error_response": runtime.editor_assist_error_response,
+ "handle_editor_assist_request": runtime.handle_editor_assist_request,
+ "authorize_group_editor_scope": runtime.authorize_group_editor_scope,
+ "is_session_admin": runtime.is_session_admin,
+ "group_agent_write_roles": lambda settings: ("Owner", "Admin"),
+ "group_agents_available": lambda user_id, settings: self.group_available,
+ "group_action_write_roles": lambda settings: ("Owner", "Admin"),
+ "group_actions_available": lambda user_id, settings: self.group_available,
+ "_create_agent_instruction_client": lambda settings: "client",
+ "_resolve_agent_instruction_model": lambda settings: "draft-model",
+ }
+ exec(compile(ast.Module(body=[route_function(kind)], type_ignores=[]), str(_path), "exec"), namespace)
+ self.app = app
+ self.route = _route
+
+ def post(self, body=None, **kwargs):
+ if body is None:
+ body = agent_body() if self.kind == "agent" else action_body()
+ with self.app.test_client() as client:
+ with client.session_transaction() as sess:
+ sess["user"] = {"roles": list(self.roles)}
+ if "data" not in kwargs:
+ kwargs["data"] = json.dumps(body)
+ kwargs.setdefault("content_type", "application/json")
+ return client.post(self.route, **kwargs)
+
+ def reached_nothing(self):
+ return self.built == [] and self.limiter.events == [] and self.model.calls == []
+
+
+@pytest.fixture(params=["agent", "action"])
+def harness(request, monkeypatch):
+ return Harness(request.param, monkeypatch)
+
+
+def assert_closed_error(response, status, code):
+ assert response.status_code == status
+ assert response.headers["Cache-Control"] == "no-store, private"
+ assert response.json["code"] == code
+ assert response.json["error"]
+ assert SECRET not in response.get_data(as_text=True)
+
+
+@pytest.mark.parametrize("kind", ["agent", "action"])
+def test_the_routes_keep_their_blueprint_swagger_and_auth_decorators(kind):
+ assert_app_version_at_least("0.261.278")
+ _path, route_path, name, bp_name = ROUTES[kind]
+ function = route_function(kind)
+ assert function.name == name
+ assert [ast.unparse(value) for value in function.decorator_list] == [
+ f"{bp_name}.route('{route_path}', methods=['POST'])",
+ "swagger_route(security=get_auth_security())",
+ "login_required",
+ "user_required",
+ ]
+
+
+def test_an_allowed_personal_request_answers_with_an_uncached_candidate(harness):
+ response = harness.post()
+
+ assert response.status_code == 200
+ assert response.headers["Cache-Control"] == "no-store, private"
+ assert response.json["outcome"] == "changed"
+ values = response.json["candidate"]["values"]
+ assert "Helper" in values.values()
+ assert SECRET not in response.get_data(as_text=True)
+ assert harness.limiter.events == [("acquire", USER_ID), ("release", False)]
+ # The model client is the draft-instructions deployment.
+ assert harness.built[0]() == ("client", "draft-model")
+
+
+def test_secret_fields_never_reach_the_model(harness):
+ harness.post()
+ [messages] = harness.model.calls
+ assert SECRET not in json.dumps(messages)
+
+
+@pytest.mark.parametrize("change", [
+ {"enable_semantic_kernel": False},
+ {"enable_agent_ai_assistant": False, "enable_action_ai_assistant": False},
+])
+def test_a_turned_off_assistant_refuses_before_any_service(harness, change):
+ harness.settings.update(change)
+ response = harness.post()
+ code = "agent_assistant_disabled" if harness.kind == "agent" else "action_assistant_disabled"
+ assert_closed_error(response, 403, code)
+ assert harness.reached_nothing()
+
+
+def test_personal_scope_follows_the_personal_toggle(harness):
+ harness.settings["allow_user_agents" if harness.kind == "agent" else "allow_user_plugins"] = False
+ assert_closed_error(harness.post(), 403, "scope_forbidden")
+ assert harness.reached_nothing()
+
+
+def test_global_scope_requires_the_admin_role(harness):
+ body = (agent_body if harness.kind == "agent" else action_body)(scope="global")
+ assert_closed_error(harness.post(body), 403, "scope_forbidden")
+ assert harness.reached_nothing()
+
+ harness.roles = ["Admin"]
+ assert harness.post(body).status_code == 200
+
+
+def test_group_scope_authorizes_the_named_group_with_editor_roles(harness):
+ body = (agent_body if harness.kind == "agent" else action_body)(scope="group", group_id=GROUP_ID)
+ assert harness.post(body).status_code == 200
+ assert harness.group_calls == [(USER_ID, GROUP_ID, ("Owner", "Admin"))]
+
+
+@pytest.mark.parametrize("error, status, code", [
+ (PermissionError("no"), 403, "scope_forbidden"),
+ (ValueError("bad"), 403, "scope_forbidden"),
+ (LookupError("missing"), 404, "scope_not_found"),
+])
+def test_group_scope_refusals_are_closed(harness, error, status, code):
+ harness.group_error = error
+ body = (agent_body if harness.kind == "agent" else action_body)(scope="group", group_id=GROUP_ID)
+ assert_closed_error(harness.post(body), status, code)
+ assert harness.reached_nothing()
+
+
+def test_unavailable_group_surface_refuses(harness):
+ harness.group_available = (False, "Group agents are not enabled.")
+ body = (agent_body if harness.kind == "agent" else action_body)(scope="group", group_id=GROUP_ID)
+ assert_closed_error(harness.post(body), 403, "scope_forbidden")
+ assert harness.group_calls == [] and harness.reached_nothing()
+
+
+@pytest.mark.parametrize("extra", [{"scope": "tenant"}, {"scope": "group", "group_id": 7}])
+def test_an_invalid_scope_is_a_bad_request(harness, extra):
+ body = (agent_body if harness.kind == "agent" else action_body)(**extra)
+ assert_closed_error(harness.post(body), 400, "invalid_request")
+ assert harness.reached_nothing()
+
+
+def test_a_non_json_body_is_a_bad_request(harness):
+ response = harness.post(data="name it", content_type="text/plain")
+ assert_closed_error(response, 400, "invalid_request")
+ assert harness.reached_nothing()
+
+
+def test_an_oversized_body_is_refused_unread(harness):
+ response = harness.post(data=b"{" + b" " * core.EDITOR_ASSIST_MAX_BODY_BYTES + b"}", content_type="application/json")
+ assert_closed_error(response, 413, "request_too_large")
+ assert harness.reached_nothing()
+
+
+if __name__ == "__main__":
+ sys.exit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_editor_assist_core.py b/functional_tests/test_editor_assist_core.py
new file mode 100644
index 000000000..6ab4d673b
--- /dev/null
+++ b/functional_tests/test_editor_assist_core.py
@@ -0,0 +1,331 @@
+#!/usr/bin/env python3
+# test_editor_assist_core.py
+"""
+Functional test for the agent and action editor Ask AI core.
+Version: 0.261.278
+Implemented in: 0.261.278
+
+This test ensures that ``functions_editor_assist`` reads the editor's field descriptors strictly,
+never sends secret fields to the model, maps choice options to opaque handles and back, applies
+``set`` and ``create_item`` operations with type variants, corrects invalid model output once,
+refunds the rate-limit lease when the model never ran, and returns closed error codes. The
+limiter's ``document_type`` keeps editor and workflow lease documents apart. No Azure service,
+model or Flask app is used.
+"""
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "application" / "single_app"))
+sys.path.insert(0, str(ROOT / "functional_tests"))
+
+import functions_editor_assist as ea # noqa: E402
+import functions_workflow_assist_limits as limits # noqa: E402
+from functions_workflow_assist import WorkflowAssistError # noqa: E402
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+
+
+USER_ID = "user-0001"
+SUBMISSION_ID = "0f8fad5b-d9cb-469f-a165-70867728950e"
+SECRET = "SECRET-VALUE-0b7e"
+
+
+class FakeLimiter:
+ def __init__(self, error=None):
+ self.events = []
+ self.error = error
+
+ def acquire(self, user_id):
+ self.events.append(("acquire", user_id))
+ if self.error is not None:
+ raise self.error
+ return "lease"
+
+ def release(self, lease, refund=False):
+ self.events.append(("release", refund))
+
+
+class ScriptedModel:
+ """Returns each scripted reply in turn; a callable reply sees the envelope."""
+
+ def __init__(self, *replies, finish="stop"):
+ self.replies = list(replies)
+ self.finish = finish
+ self.calls = []
+
+ def __call__(self, messages, timeout):
+ self.calls.append(messages)
+ reply = self.replies.pop(0)
+ if isinstance(reply, Exception):
+ raise reply
+ if callable(reply):
+ reply = reply(json.loads(messages[1]["content"]))
+ return (reply if isinstance(reply, str) else json.dumps(reply)), self.finish
+
+
+def run(body, model, *, kind="agent", limiter=None):
+ services = ea.EditorAssistServices(limiter=limiter or FakeLimiter(), call_model=model)
+ return ea.run_editor_assist(body, kind=kind, scope="personal", user_id=USER_ID, services=services)
+
+
+def agent_body(**extra):
+ body = {
+ "submission_id": SUBMISSION_ID,
+ "instruction": "Make it a helpful SQL agent.",
+ "sections": [{"id": "identity", "label": "Identity"}, {"id": "actions", "label": "Actions"}],
+ "fields": [
+ {"path": "/display_name", "label": "Name", "section": "identity", "kind": "text", "required": True},
+ {"path": "/instructions", "label": "Instructions", "section": "identity", "kind": "textarea"},
+ {"path": "/max_completion_tokens", "label": "Max tokens", "kind": "number", "min": 1, "max": 4000, "integer": True},
+ {"path": "/actions", "label": "Actions", "section": "actions", "kind": "choices", "options": [
+ {"value": "action-a", "label": "Weather"}, {"value": "action-b", "label": "Search"},
+ ]},
+ ],
+ "values": {"/display_name": "Old", "/instructions": "", "/max_completion_tokens": 100, "/actions": ["action-a"]},
+ }
+ body.update(extra)
+ return body
+
+
+def handle_for(envelope, path, value_label):
+ field = next(item for item in envelope["draft"]["fields"] if item["path"] == path)
+ return next(option["handle"] for option in field["options"] if option["label"] == value_label)
+
+
+def test_version():
+ assert_app_version_at_least("0.261.278")
+
+
+def test_set_operations_change_the_candidate_and_report_changes():
+ model = ScriptedModel(lambda env: {"reply": "Done.", "operations": [
+ {"op": "set", "path": "/display_name", "value": "SQL helper"},
+ {"op": "set", "path": "/actions", "value": [handle_for(env, "/actions", "Search")]},
+ ]})
+ limiter = FakeLimiter()
+ result = run(agent_body(), model, limiter=limiter)
+
+ assert result["outcome"] == "changed"
+ assert result["candidate"]["values"]["/display_name"] == "SQL helper"
+ assert result["candidate"]["values"]["/actions"] == ["action-b"]
+ assert [change["path"] for change in result["changes"]] == ["/display_name", "/actions"]
+ assert limiter.events == [("acquire", USER_ID), ("release", False)]
+ # Real option values never reach the model; only handles do.
+ assert "action-b" not in json.dumps(model.calls[0])
+
+
+def test_secret_fields_are_dropped_before_the_model():
+ body = {
+ "submission_id": SUBMISSION_ID,
+ "instruction": "Name it.",
+ "fields": [
+ {"path": "/displayName", "label": "Name", "kind": "text"},
+ {"path": "/additionalFields/api_key", "label": "Key", "kind": "text"},
+ {"path": "/additionalFields/client_secret", "label": "Secret", "kind": "text"},
+ {"path": "/additionalFields/token_hint", "label": "Hint", "kind": "secret"},
+ ],
+ "values": {
+ "/displayName": "x", "/additionalFields/api_key": SECRET,
+ "/additionalFields/client_secret": SECRET, "/additionalFields/token_hint": SECRET,
+ },
+ }
+ model = ScriptedModel({"reply": "Ok.", "operations": [{"op": "set", "path": "/displayName", "value": "Y"}]})
+ result = run(body, model, kind="action")
+
+ assert SECRET not in json.dumps(model.calls)
+ assert SECRET not in json.dumps(result)
+
+
+def test_the_model_cannot_set_a_secret_or_undescribed_path():
+ body = agent_body()
+ bad = {"reply": "Ok.", "operations": [{"op": "set", "path": "/model", "value": "gpt"}]}
+ fixed = {"reply": "Ok.", "operations": [{"op": "set", "path": "/display_name", "value": "New"}]}
+ model = ScriptedModel(bad, fixed)
+ result = run(body, model)
+
+ assert len(model.calls) == 2 # one correction round
+ assert result["candidate"]["values"]["/display_name"] == "New"
+ assert "/model" not in result["candidate"]["values"]
+
+
+@pytest.mark.parametrize("path, expected", [
+ ("/additionalFields/api_key", True),
+ ("/additionalFields/access_token", True),
+ ("/additionalFields/connection_string", True),
+ ("/max_completion_tokens", False),
+ ("/display_name", False),
+])
+def test_is_secret_path(path, expected):
+ assert ea.is_secret_path(path) is expected
+
+
+def test_number_limits_are_corrected_then_refused():
+ over = {"reply": "Ok.", "operations": [{"op": "set", "path": "/max_completion_tokens", "value": 99999}]}
+ model = ScriptedModel(over, over)
+ with pytest.raises(ea.EditorAssistError) as caught:
+ run(agent_body(), model)
+ assert caught.value.code == "assistant_output_invalid"
+ assert len(model.calls) == 2
+
+
+def test_an_explanation_changes_nothing():
+ model = ScriptedModel({"reply": "Agents call actions to reach tools.", "operations": []})
+ result = run(agent_body(), model)
+ assert result["outcome"] == "explained"
+ assert result["candidate"] is None and result["changes"] == []
+
+
+def test_operations_that_change_nothing_are_explained():
+ model = ScriptedModel({"reply": "Renamed.", "operations": [{"op": "set", "path": "/display_name", "value": "Old"}]})
+ result = run(agent_body(), model)
+ assert result["outcome"] == "explained"
+ assert result["reply"] == ea._NO_CHANGE_REPLY
+
+
+def test_create_item_drafts_a_new_action_and_assigns_it():
+ new_items = {
+ "target": "/actions", "noun": "action", "max": 2,
+ "types": [{"value": "sql_query", "label": "SQL query"}],
+ "common": [{"path": "/displayName", "label": "Name", "kind": "text", "required": True}],
+ "variants": {"sql_query": [
+ {"path": "/additionalFields/max_rows", "label": "Rows", "kind": "number", "min": 1, "max": 100, "integer": True},
+ {"path": "/additionalFields/connection_string", "label": "Connection", "kind": "text"},
+ ]},
+ }
+ model = ScriptedModel({"reply": "Drafted.", "operations": [{
+ "op": "create_item", "handle": "N1", "type": "sql_query",
+ "values": {"/displayName": "Orders", "/additionalFields/max_rows": 25},
+ }]})
+ result = run(agent_body(new_items=new_items), model)
+
+ assert result["candidate"]["values"]["/actions"] == ["action-a", "new:N1"]
+ assert result["candidate"]["new_items"] == [{
+ "handle": "new:N1", "type": "sql_query",
+ "values": {"/displayName": "Orders", "/additionalFields/max_rows": 25},
+ }]
+ # Secret fields of a drafted type are never offered to the model.
+ assert "connection_string" not in json.dumps(model.calls[0])
+
+
+def test_new_items_are_agent_only():
+ body = {
+ "submission_id": SUBMISSION_ID, "instruction": "x",
+ "fields": [{"path": "/displayName", "label": "Name"}], "values": {},
+ "new_items": {"target": "/displayName", "noun": "x", "types": [], "common": [], "variants": {}},
+ }
+ with pytest.raises(ea.EditorAssistError) as caught:
+ run(body, ScriptedModel())
+ assert caught.value.status == 400
+
+
+def test_a_type_change_validates_against_the_new_variant():
+ body = {
+ "submission_id": SUBMISSION_ID, "instruction": "Make it a SQL action.",
+ "fields": [
+ {"path": "/displayName", "label": "Name", "kind": "text"},
+ {"path": "/type", "label": "Type", "kind": "select", "options": [
+ {"value": "openapi", "label": "OpenAPI"}, {"value": "sql_query", "label": "SQL"},
+ ]},
+ ],
+ "variant": {"path": "/type", "fields": {
+ "openapi": [{"path": "/endpoint", "label": "Endpoint", "kind": "text"}],
+ "sql_query": [{"path": "/additionalFields/database", "label": "Database", "kind": "text", "required": True}],
+ }},
+ "values": {"/displayName": "A", "/type": "openapi", "/endpoint": "https://example.com"},
+ }
+ model = ScriptedModel({"reply": "Switched.", "operations": [
+ {"op": "set", "path": "/type", "value": "sql_query"},
+ ]})
+ result = run(body, model, kind="action")
+
+ values = result["candidate"]["values"]
+ assert values["/type"] == "sql_query"
+ assert "/endpoint" not in values
+ assert {"code": "required_fields_missing", "paths": ["/additionalFields/database"]} in [
+ {key: warning.get(key) for key in ("code", "paths")} for warning in result["warnings"]
+ ]
+
+
+@pytest.mark.parametrize("mutate, code", [
+ (lambda body: body.pop("instruction"), "invalid_request"),
+ (lambda body: body.update(instruction=""), "instruction_invalid"),
+ (lambda body: body.update(surprise=True), "invalid_request"),
+ (lambda body: body.update(focus="nowhere"), "focus_invalid"),
+])
+def test_invalid_requests_are_refused_before_the_limiter(mutate, code):
+ body = agent_body()
+ mutate(body)
+ limiter = FakeLimiter()
+ with pytest.raises(ea.EditorAssistError) as caught:
+ run(body, ScriptedModel(), limiter=limiter)
+ assert caught.value.code == code
+ assert limiter.events == []
+
+
+def test_a_model_failure_before_any_call_is_refunded():
+ limiter = FakeLimiter()
+ model = ScriptedModel(WorkflowAssistError("assistant_unavailable"))
+ with pytest.raises(ea.EditorAssistError) as caught:
+ run(agent_body(), model, limiter=limiter)
+ assert caught.value.code == "assistant_unavailable"
+ assert limiter.events[-1][0] == "release"
+
+
+def test_shared_limiter_errors_are_converted():
+ limiter = FakeLimiter(error=WorkflowAssistError("assistant_busy"))
+ with pytest.raises(ea.EditorAssistError) as caught:
+ run(agent_body(), ScriptedModel(), limiter=limiter)
+ assert (caught.value.code, caught.value.status) == ("assistant_busy", 429)
+
+
+def test_a_content_filter_finish_is_a_refusal():
+ model = ScriptedModel({"reply": "", "operations": []}, finish="content_filter")
+ with pytest.raises(ea.EditorAssistError) as caught:
+ run(agent_body(), model)
+ assert caught.value.code == "assistant_refused"
+
+
+def test_parse_body_is_strict_json():
+ for raw in (b"{\"a\": NaN}", b"not json", "{}".encode("utf-16")):
+ with pytest.raises(ea.EditorAssistError):
+ ea.parse_editor_assist_body(raw)
+ assert ea.parse_editor_assist_body(b"{\"a\": 1}") == {"a": 1}
+
+
+def test_limiter_document_type_keeps_editor_and_workflow_leases_apart():
+ workflow_id = limits.assist_limit_document_id(USER_ID)
+ editor_id = limits.assist_limit_document_id(USER_ID, document_type="editor_assist_rate_limit")
+ assert workflow_id != editor_id
+ assert limits.assist_limit_document_id(USER_ID) == workflow_id
+
+
+def _settings_helpers():
+ import ast
+ path = ROOT / "application" / "single_app" / "functions_settings.py"
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ names = ("is_agent_assistant_enabled", "is_action_assistant_enabled")
+ nodes = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names]
+ namespace = {}
+ exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), namespace)
+ return path.read_text(encoding="utf-8"), namespace
+
+
+@pytest.mark.parametrize("helper, key", [
+ ("is_agent_assistant_enabled", "enable_agent_ai_assistant"),
+ ("is_action_assistant_enabled", "enable_action_ai_assistant"),
+])
+def test_admin_toggles_default_on_and_need_agents(helper, key):
+ source, helpers = _settings_helpers()
+ enabled = helpers[helper]
+ assert f"'{key}': True" in source
+ assert enabled({"enable_semantic_kernel": True}) is True
+ assert enabled({"enable_semantic_kernel": True, key: False}) is False
+ assert enabled({"enable_semantic_kernel": False, key: True}) is False
+ assert enabled(None) is False
+
+
+if __name__ == "__main__":
+ sys.exit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_v2_action_editor_assist_logic.mjs b/functional_tests/test_v2_action_editor_assist_logic.mjs
new file mode 100644
index 000000000..638900816
--- /dev/null
+++ b/functional_tests/test_v2_action_editor_assist_logic.mjs
@@ -0,0 +1,162 @@
+// test_v2_action_editor_assist_logic.mjs
+// Version: 0.261.280
+// Implemented in: 0.261.280
+// Executes the action editor's Ask AI view and patch rules: the per-type fields Ask AI may read
+// and set, which paths it never sees, and how a type change and its undo go through the editor's
+// own helpers.
+
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import './test_support/tsResolve.mjs';
+
+const { actionAuthMethod, actionTypeLabel, changeActionType, createActionDraft } = await import('../application/v2_ui/src/lib/workspaceActionLogic.ts');
+const { EDITOR_SECRET_MASK } = await import('../application/v2_ui/src/lib/workspaceAuthoring.ts');
+const { editorAssistPatch, editorAssistUndoPlan } = await import('../application/v2_ui/src/lib/editorAssist.ts');
+const {
+ ACTION_ASSIST_SECTIONS, actionAssistValues, applyActionAssistPatch, buildActionAssistView,
+} = await import('../application/v2_ui/src/lib/actionEditorAssist.ts');
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const schemas = path.join(root, 'application', 'single_app', 'static', 'json', 'schemas');
+const readSchema = (name) => {
+ const location = path.join(schemas, name);
+ return fs.existsSync(location) ? JSON.parse(fs.readFileSync(location, 'utf8')) : {};
+};
+const baseAuth = readSchema('plugin.schema.json').definitions.AuthType.enum;
+function definition(type) {
+ return {
+ type, display: actionTypeLabel(type), description: `Fixture for ${type}`,
+ allowed_auth_types: readSchema(`${type}.definition.json`).allowedAuthTypes || baseAuth,
+ additional_fields_schema: readSchema(`${type}_plugin.additional_settings.schema.json`),
+ metadata_schema: readSchema(`${type}_plugin.metadata.schema.json`),
+ };
+}
+const catalogue = ['openapi', 'mcp', 'sql_query', 'simplechat', 'blob_storage', 'msgraph'].map(definition);
+const context = { catalogue, isNew: true, secretPaths: [] };
+const ACTION_PATH = /^\/(displayName|description|type|endpoint|deployment|api_version|auth\/type|additionalFields\/[A-Za-z0-9_]{1,80}|metadata\/[A-Za-z0-9_]{1,80})$/;
+
+function typed(type) {
+ return { ...changeActionType(createActionDraft(), definition(type)), displayName: 'Orders', description: 'Reads orders.' };
+}
+
+function allPaths(view) {
+ return [...view.fields, ...Object.values(view.variant?.fields ?? {}).flat()].map((field) => field.path);
+}
+
+function testViewShape() {
+ const view = buildActionAssistView(createActionDraft(), context);
+ assert.deepEqual(view.sections.map((section) => section.id), ACTION_ASSIST_SECTIONS.map((section) => section.id));
+ assert.deepEqual(view.fields.map((field) => field.path), ['/displayName', '/description', '/type']);
+ assert.equal(view.variant.path, '/type');
+ assert.deepEqual(Object.keys(view.variant.fields).sort(), catalogue.map((item) => item.type).sort());
+ assert.ok(view.variant.fields.mcp.some((field) => field.path === '/additionalFields/allowed_tool_names' && field.kind === 'lines'));
+ assert.ok(view.variant.fields.simplechat.some((field) => field.path === '/additionalFields/simplechat_capabilities' && field.kind === 'choices'));
+ assert.ok(view.variant.fields.sql_query.some((field) => field.path === '/auth/type' && field.kind === 'select'));
+ for (const item of allPaths(view)) assert.match(item, ACTION_PATH, `${item} would be refused by the server`);
+ for (const fields of Object.values(view.variant.fields)) {
+ const paths = fields.map((field) => field.path);
+ assert.equal(new Set(paths).size, paths.length);
+ }
+ assert.deepEqual(view.values, { '/displayName': '', '/description': '', '/type': null });
+}
+
+function testSecretsHidden() {
+ const draft = typed('sql_query');
+ draft.additionalFields = { ...draft.additionalFields, password: EDITOR_SECRET_MASK, server: 'db.example' };
+ const view = buildActionAssistView(draft, { ...context, isNew: false, secretPaths: ['/additionalFields/server'] });
+ const paths = allPaths(view);
+ assert.ok(!paths.includes('/additionalFields/server'));
+ assert.ok(!paths.some((item) => /password|secret|connection_string/i.test(item)));
+ assert.ok(!paths.some((item) => item.toLowerCase().endsWith('key')));
+ assert.ok(!Object.values(view.values).includes(EDITOR_SECRET_MASK));
+}
+
+function testTypeChangeAndUndo() {
+ const start = typed('sql_query');
+ const filled = applyActionAssistPatch(start, { '/additionalFields/server': 'db.example', '/additionalFields/database': 'orders' }, context);
+ assert.equal(filled.additionalFields.server, 'db.example');
+ const before = actionAssistValues(filled, context);
+ const patch = editorAssistPatch(buildActionAssistView(filled, context), before, {
+ ...before, '/type': 'openapi', '/endpoint': 'https://api.example/openapi.json',
+ });
+ assert.equal(Object.keys(patch)[0], '/type');
+ const changed = applyActionAssistPatch(filled, patch, context);
+ assert.equal(changed.type, 'openapi');
+ assert.equal(changed.endpoint, 'https://api.example/openapi.json');
+ const after = actionAssistValues(changed, context);
+ const entries = Object.keys(patch).map((key) => ({ path: key, before: before[key] ?? null, after: after[key] }));
+ const plan = editorAssistUndoPlan(entries, before, after, '/type');
+ const undone = applyActionAssistPatch(changed, plan.patch, context);
+ assert.ok(undone, 'undo is applied');
+ assert.equal(undone.type, 'sql_query');
+ assert.equal(undone.additionalFields.server, 'db.example');
+ assert.equal(undone.additionalFields.database, 'orders');
+}
+
+function testUndoToNoType() {
+ const empty = { ...createActionDraft(), displayName: 'Orders' };
+ const before = actionAssistValues(empty, context);
+ const changed = applyActionAssistPatch(empty, { '/type': 'mcp', '/endpoint': 'https://mcp.example/sse' }, context);
+ assert.equal(changed.type, 'mcp');
+ const after = actionAssistValues(changed, context);
+ const entries = [{ path: '/type', before: null, after: 'mcp' }, { path: '/endpoint', before: null, after: after['/endpoint'] }];
+ const plan = editorAssistUndoPlan(entries, before, after, '/type');
+ const undone = applyActionAssistPatch(changed, plan.patch, context);
+ assert.ok(undone, 'undo back to no type is applied');
+ assert.equal(undone.type, '');
+ assert.equal(undone.endpoint, '');
+ assert.equal(undone._actionTypeConfigurations.mcp.endpoint, 'https://mcp.example/sse');
+}
+
+function testFieldKinds() {
+ const mcp = applyActionAssistPatch(typed('mcp'), {
+ '/additionalFields/allowed_tool_names': ['search', 'lookup'],
+ '/additionalFields/load_prompts': true,
+ '/additionalFields/tool_result_policy': 'truncate',
+ }, context);
+ assert.deepEqual(mcp.additionalFields.allowed_tool_names, ['search', 'lookup']);
+ assert.equal(mcp.additionalFields.load_prompts, true);
+ assert.equal(applyActionAssistPatch(typed('mcp'), { '/additionalFields/tool_result_policy': 'shout' }, context), null);
+
+ const view = buildActionAssistView(typed('simplechat'), context);
+ const capability = view.variant.fields.simplechat.find((field) => field.path === '/additionalFields/simplechat_capabilities');
+ const first = capability.options[0].value;
+ const chosen = applyActionAssistPatch(typed('simplechat'), { '/additionalFields/simplechat_capabilities': [first] }, context);
+ assert.deepEqual(actionAssistValues(chosen, context)['/additionalFields/simplechat_capabilities'], [first]);
+
+ const sqlView = buildActionAssistView(typed('sql_query'), context);
+ const auth = sqlView.variant.fields.sql_query.find((field) => field.path === '/auth/type');
+ const other = auth.options.map((option) => option.value).find((value) => value !== actionAuthMethod(typed('sql_query')));
+ if (other) assert.equal(actionAuthMethod(applyActionAssistPatch(typed('sql_query'), { '/auth/type': other }, context)), other);
+}
+
+function testRefusals() {
+ const draft = typed('openapi');
+ assert.equal(applyActionAssistPatch(draft, { '/type': 'not_a_type' }, context), null);
+ assert.equal(applyActionAssistPatch(draft, { '/additionalFields/server': 'x' }, context), null);
+ assert.equal(applyActionAssistPatch(draft, { '/auth/key': 'x' }, context), null);
+ const unchanged = applyActionAssistPatch(draft, { '/displayName': 'Orders' }, context);
+ assert.equal(unchanged.displayName, 'Orders');
+}
+
+function testEveryTypeMeetsServerRules() {
+ const types = fs.readdirSync(schemas).filter((name) => name.endsWith('.definition.json')).map((name) => name.replace('.definition.json', ''));
+ const every = [...new Set([...types, 'openapi', 'mcp'])].map(definition);
+ const view = buildActionAssistView(typed('openapi'), { ...context, catalogue: every });
+ for (const field of [...view.fields, ...Object.values(view.variant.fields).flat()]) {
+ assert.match(field.path, ACTION_PATH, `${field.path} would be refused by the server`);
+ for (const option of field.options ?? []) {
+ assert.ok(typeof option.value === 'string' && option.value && option.value.length <= 512, `${field.path} has a blank option`);
+ }
+ if (field.kind === 'select' || field.kind === 'choices') assert.ok(field.options?.length, `${field.path} lists no options`);
+ }
+}
+
+const tests = [testViewShape, testSecretsHidden, testTypeChangeAndUndo, testUndoToNoType, testFieldKinds, testRefusals, testEveryTypeMeetsServerRules];
+for (const test of tests) {
+ test();
+ console.log(`✅ ${test.name}`);
+}
+console.log(`📊 ${tests.length}/${tests.length} action editor Ask AI checks passed`);
diff --git a/functional_tests/test_v2_agent_drafted_actions_logic.mjs b/functional_tests/test_v2_agent_drafted_actions_logic.mjs
new file mode 100644
index 000000000..897708a1c
--- /dev/null
+++ b/functional_tests/test_v2_agent_drafted_actions_logic.mjs
@@ -0,0 +1,148 @@
+// test_v2_agent_drafted_actions_logic.mjs
+// Version: 0.261.281
+// Implemented in: 0.261.281
+// Executes how the agent editor's Ask AI drafts new actions: the new-action spec it sends, how a
+// turn's handles become session references, how undo and Remove drop a drafted action and bring
+// it back, and how a saved action replaces its placeholder before the agent is saved.
+
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import './test_support/tsResolve.mjs';
+
+const { newAgentDraft } = await import('../application/v2_ui/src/lib/workspaceAgentAuthoring.ts');
+const { actionForSave, actionTypeLabel, validateActionDraft } = await import('../application/v2_ui/src/lib/workspaceActionLogic.ts');
+const { draftActionFromAssist, newActionAssistSpec } = await import('../application/v2_ui/src/lib/actionEditorAssist.ts');
+const {
+ applyAgentAssistPatch, buildAgentAssistView, isPendingActionReference, pendingAgentActions,
+ prunePendingAgentActions, resolvePendingAgentAction,
+} = await import('../application/v2_ui/src/lib/agentEditorAssist.ts');
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const schemas = path.join(root, 'application', 'single_app', 'static', 'json', 'schemas');
+const readSchema = (name) => {
+ const location = path.join(schemas, name);
+ return fs.existsSync(location) ? JSON.parse(fs.readFileSync(location, 'utf8')) : {};
+};
+const baseAuth = readSchema('plugin.schema.json').definitions.AuthType.enum;
+function definition(type) {
+ return {
+ type, display: actionTypeLabel(type), description: `Fixture for ${type}`,
+ allowed_auth_types: readSchema(`${type}.definition.json`).allowedAuthTypes || baseAuth,
+ additional_fields_schema: readSchema(`${type}_plugin.additional_settings.schema.json`),
+ metadata_schema: readSchema(`${type}_plugin.metadata.schema.json`),
+ };
+}
+const actionTypes = ['openapi', 'mcp', 'simplechat', 'agent'].map(definition);
+const options = {
+ agent_types: [{ value: 'local', label: 'Local', enabled: true }],
+ settings: {},
+ model_endpoints: [],
+ builtin_actions: [],
+};
+const actions = [{ id: 'act-search', name: 'search', displayName: 'Web search', type: 'openapi' }];
+let sequence = 0;
+const stash = new Map();
+const base = {
+ options, actions, targets: null, knowledge: null, knowledgeScopes: ['personal'],
+ reasoningLevels: [], ownerId: 'owner', isNew: true,
+};
+const drafting = {
+ ...base, actionTypes,
+ newPendingReference: () => `new-action-test-${++sequence}`,
+ recallPendingAction: (reference) => stash.get(reference),
+};
+
+function draft(overrides = {}) {
+ return { ...newAgentDraft(), display_name: 'Helper', description: 'Helps.', instructions: 'Be kind.', ...overrides };
+}
+
+function mcpItem(handle, displayName = 'Docs MCP') {
+ return { handle, type: 'mcp', values: { '/displayName': displayName, '/description': 'Reads the docs.', '/endpoint': 'https://mcp.example.com/sse' } };
+}
+
+// The view offers new-action drafting only when the catalogue and a reference source are present.
+{
+ const plain = buildAgentAssistView(draft(), base);
+ assert.equal(plain.newItems, undefined);
+ const view = buildAgentAssistView(draft(), drafting);
+ assert.equal(view.newItems.target, '/actions');
+ assert.equal(view.newItems.max, 3);
+ const types = view.newItems.types.map((option) => option.value);
+ assert.ok(types.includes('mcp') && types.includes('openapi'));
+ assert.ok(!types.includes('agent'), 'Call agent actions are created in the action editor');
+ for (const descriptor of view.newItems.common) assert.match(descriptor.path, /^\/(displayName|description)$/);
+ assert.ok(view.notes.some((note) => note.includes('credentials')));
+ // Write the request so the backend contract check can parse it.
+ if (process.env.AGENT_DRAFT_VIEW_OUT) {
+ const { buildEditorAssistRequest } = await import('../application/v2_ui/src/lib/editorAssist.ts');
+ const request = buildEditorAssistRequest({
+ scope: { kind: 'personal' }, submissionId: 'test-submission', instruction: 'Add a docs action.', view, conversation: [],
+ });
+ fs.writeFileSync(process.env.AGENT_DRAFT_VIEW_OUT, JSON.stringify(request));
+ }
+}
+
+// The spec and draft helpers build an action through the action editor's own rules.
+{
+ const spec = newActionAssistSpec({ catalogue: actionTypes, isNew: true });
+ assert.ok(Object.keys(spec.variants).every((type) => spec.types.some((option) => option.value === type)));
+ const action = draftActionFromAssist('mcp', mcpItem('new:N1').values, { catalogue: actionTypes, isNew: true });
+ assert.equal(action.type, 'mcp');
+ assert.equal(action.displayName, 'Docs MCP');
+ assert.match(action.name, /^[A-Za-z0-9_-]+$/);
+ assert.equal(draftActionFromAssist('agent', { '/displayName': 'x' }, { catalogue: actionTypes, isNew: true }), null);
+ assert.equal(draftActionFromAssist('missing', { '/displayName': 'x' }, { catalogue: actionTypes, isNew: true }), null);
+}
+
+// A turn's handles become session references, and the drafted actions travel with the draft.
+let withDraft;
+{
+ const start = draft({ actions_to_load: ['act-search'] });
+ withDraft = applyAgentAssistPatch(start, { '/actions': ['act-search', 'new:N1'] }, drafting, [mcpItem('new:N1')]);
+ assert.ok(withDraft);
+ const [reference] = withDraft.actions_to_load.filter(isPendingActionReference);
+ assert.equal(reference, 'new-action-test-1');
+ assert.deepEqual(withDraft.actions_to_load, ['act-search', reference]);
+ const pending = pendingAgentActions(withDraft);
+ assert.equal(pending.length, 1);
+ assert.equal(pending[0].action.displayName, 'Docs MCP');
+ for (const item of pending) stash.set(item.reference, item.action);
+
+ // The drafted action is offered back to Ask AI as an assignable option.
+ const view = buildAgentAssistView(withDraft, drafting);
+ const option = view.fields.find((field) => field.path === '/actions').options.find((item) => item.value === reference);
+ assert.ok(option && option.label === 'Docs MCP');
+ assert.equal(view.newItems.max, 2);
+
+ // Without drafting support, an unknown handle, or too many drafts, the patch changes nothing.
+ assert.equal(applyAgentAssistPatch(start, { '/actions': ['new:N1'] }, base, [mcpItem('new:N1')]), null);
+ assert.equal(applyAgentAssistPatch(start, { '/actions': ['new:N2'] }, drafting, [mcpItem('new:N1')]), null);
+ assert.equal(applyAgentAssistPatch(start, { '/actions': [] }, drafting,
+ ['new:N1', 'new:N2', 'new:N3', 'new:N4'].map((handle) => mcpItem(handle))), null);
+}
+
+// Undo or Remove drops the drafted action; a later undo brings it back from the stash.
+{
+ const undone = applyAgentAssistPatch(withDraft, { '/actions': ['act-search'] }, drafting);
+ assert.deepEqual(undone.actions_to_load, ['act-search']);
+ assert.equal(Object.hasOwn(undone, '_pendingActions'), false);
+ const removed = prunePendingAgentActions({ ...withDraft, actions_to_load: ['act-search'] });
+ assert.equal(Object.hasOwn(removed, '_pendingActions'), false);
+ const redone = applyAgentAssistPatch(undone, { '/actions': ['act-search', 'new-action-test-1'] }, drafting);
+ assert.equal(pendingAgentActions(redone)[0].action.displayName, 'Docs MCP');
+ assert.equal(applyAgentAssistPatch(undone, { '/actions': ['new-action-unknown'] }, drafting), null);
+}
+
+// A drafted action without required details is reported, and saving replaces the placeholder in place.
+{
+ const [{ reference, action }] = pendingAgentActions(withDraft);
+ const errors = validateActionDraft(actionForSave(action), definition('mcp'));
+ assert.equal(typeof errors, 'object');
+ const saved = resolvePendingAgentAction(withDraft, reference, 'act-docs');
+ assert.deepEqual(saved.actions_to_load, ['act-search', 'act-docs']);
+ assert.equal(Object.hasOwn(saved, '_pendingActions'), false);
+}
+
+console.log('agent drafted actions logic: ok');
diff --git a/functional_tests/test_v2_agent_editor_assist_logic.mjs b/functional_tests/test_v2_agent_editor_assist_logic.mjs
new file mode 100644
index 000000000..508c8b9fd
--- /dev/null
+++ b/functional_tests/test_v2_agent_editor_assist_logic.mjs
@@ -0,0 +1,148 @@
+// test_v2_agent_editor_assist_logic.mjs
+// Version: 0.261.279
+// Implemented in: 0.261.279
+// Executes the agent editor's Ask AI view and patch rules: which fields Ask AI may read and set,
+// which actions it may assign, and how its answer is applied through the editor's own helpers.
+
+import assert from 'node:assert/strict';
+import './test_support/tsResolve.mjs';
+
+const { newAgentDraft } = await import('../application/v2_ui/src/lib/workspaceAgentAuthoring.ts');
+const { readAgentKnowledge } = await import('../application/v2_ui/src/lib/workspaceAgentKnowledge.ts');
+const {
+ AGENT_ASSIST_SECTIONS, agentAssistValues, applyAgentAssistPatch, buildAgentAssistView,
+} = await import('../application/v2_ui/src/lib/agentEditorAssist.ts');
+
+const options = {
+ agent_types: [{ value: 'local', label: 'Local', enabled: true }, { value: 'aifoundry', label: 'Foundry', enabled: true }],
+ settings: {},
+ model_endpoints: [{
+ id: 'ep-1', name: 'Main', provider: 'aoai', enabled: true,
+ models: [{ id: 'm-1', deploymentName: 'gpt-main', modelName: 'gpt-main', displayName: 'GPT main', enabled: true }],
+ }],
+ builtin_actions: [],
+};
+const actions = [
+ { id: 'act-search', name: 'search', displayName: 'Web search', type: 'openapi', description: 'Searches the web.' },
+ { id: 'act-off', name: 'disabled', displayName: 'Old action', type: 'openapi', is_enabled: false },
+ { id: 'act-legacy', name: 'legacy', displayName: 'Legacy', type: 'sql_query' },
+];
+const knowledge = {
+ sources: [
+ { scope: 'personal', id: 'personal', label: 'My workspace' },
+ { scope: 'group', id: 'g-1', label: 'Group one' },
+ { scope: 'public', id: 'p-1', label: 'Public one' },
+ ],
+ documents: [{ id: 'doc-1', title: 'Handbook', file_name: 'handbook.pdf', scope: 'personal', source_id: 'personal', source_name: 'My workspace', tags: ['hr'] }],
+ tags: [{ name: 'hr', count: 1 }],
+};
+const context = {
+ options, actions, targets: null, knowledge, knowledgeScopes: ['personal', 'public'],
+ reasoningLevels: ['low', 'high'], ownerId: 'owner', isNew: true,
+};
+
+function draft(overrides = {}) {
+ return { ...newAgentDraft(), display_name: 'Helper', description: 'Helps.', instructions: 'Be kind.', ...overrides };
+}
+
+function fieldPaths(view) {
+ return view.fields.map((field) => field.path);
+}
+
+// A local agent describes identity, model, actions, knowledge and limits, but never connections or secrets.
+{
+ const view = buildAgentAssistView(draft({ actions_to_load: ['legacy'] }), context);
+ assert.deepEqual(view.sections, AGENT_ASSIST_SECTIONS);
+ const paths = fieldPaths(view);
+ for (const path of ['/display_name', '/description', '/instructions', '/agent_type', '/model', '/actions',
+ '/max_completion_tokens', '/reasoning_effort', '/knowledge/enabled', '/knowledge/sources',
+ '/knowledge/documents', '/knowledge/tags', '/knowledge/web_sources']) {
+ assert.ok(paths.includes(path), `missing ${path}`);
+ }
+ assert.ok(!paths.some((path) => /key|secret|endpoint|deployment|other_settings/.test(path)), 'no connection or secret fields');
+ assert.equal(view.fields.find((field) => field.path === '/agent_type').read_only, true);
+ for (const field of view.fields) {
+ for (const option of field.options ?? []) assert.ok(option.value, `${field.path} has an empty option value`);
+ }
+ const actionField = view.fields.find((field) => field.path === '/actions');
+ // A disabled action can't be assigned; an assigned action keeps the reference the draft uses.
+ assert.deepEqual(actionField.options.map((option) => option.value), ['act-search', 'legacy']);
+ const sources = view.fields.find((field) => field.path === '/knowledge/sources');
+ assert.deepEqual(sources.options.map((option) => option.value), ['personal:personal', 'public:p-1'], 'only authorized scopes');
+ assert.deepEqual(view.values['/actions'], ['legacy']);
+ assert.equal(view.values['/reasoning_effort'], null);
+}
+
+// A Foundry agent only offers identity and description changes.
+{
+ const view = buildAgentAssistView(draft({ agent_type: 'aifoundry' }), context);
+ assert.deepEqual(fieldPaths(view), ['/display_name', '/description', '/agent_type', '/instructions']);
+ assert.ok(view.notes.some((note) => note.includes('Foundry')));
+}
+
+// Without the knowledge catalogue, only the toggle and web pages are offered.
+{
+ const view = buildAgentAssistView(draft(), { ...context, knowledge: null });
+ const paths = fieldPaths(view);
+ assert.ok(!paths.includes('/knowledge/sources') && !paths.includes('/knowledge/documents'));
+ assert.ok(paths.includes('/knowledge/web_sources'));
+}
+
+// Applying a patch goes through the editor's helpers.
+{
+ const start = draft();
+ const modelKey = buildAgentAssistView(start, context).fields.find((field) => field.path === '/model').options[0].value;
+ const next = applyAgentAssistPatch(start, {
+ '/display_name': 'Research helper',
+ '/model': modelKey,
+ '/actions': ['act-search', 'act-search'],
+ '/reasoning_effort': 'high',
+ '/max_completion_tokens': 4000,
+ '/knowledge/enabled': true,
+ '/knowledge/sources': ['personal:personal', 'public:p-1'],
+ '/knowledge/documents': ['doc-1'],
+ '/knowledge/tags': ['hr'],
+ '/knowledge/web_sources': [{ url: 'https://example.com/a', mode: 'deep_research' }],
+ }, context);
+ assert.ok(next);
+ assert.equal(next.display_name, 'Research helper');
+ assert.equal(next.name, 'Research-helper', 'a new agent renames with its display name');
+ assert.equal(next.model_endpoint_id, 'ep-1');
+ assert.equal(next.model_id, 'm-1');
+ assert.deepEqual(next.actions_to_load, ['act-search']);
+ assert.equal(next.reasoning_effort, 'high');
+ assert.equal(next.max_completion_tokens, 4000);
+ const config = readAgentKnowledge(next);
+ assert.equal(config.enabled, true);
+ assert.equal(config.scopes.personal, true);
+ assert.deepEqual(config.scopes.public_workspace_ids, ['p-1']);
+ assert.deepEqual(config.scopes.group_ids, []);
+ assert.deepEqual(config.document_ids, ['doc-1']);
+ assert.deepEqual(config.tags, ['hr']);
+ assert.deepEqual(config.web_sources.map(({ url, mode }) => ({ url, mode })), [{ url: 'https://example.com/a', mode: 'deep_research' }]);
+ const values = agentAssistValues(next, context);
+ assert.equal(values['/model'], modelKey);
+ assert.deepEqual(values['/knowledge/sources'], ['personal:personal', 'public:p-1']);
+
+ // Clearing reasoning effort removes it, so the configured default applies.
+ const cleared = applyAgentAssistPatch(next, { '/reasoning_effort': null }, context);
+ assert.equal(Object.hasOwn(cleared, 'reasoning_effort'), false);
+}
+
+// A patch the editor can't take changes nothing.
+{
+ const start = draft();
+ assert.equal(applyAgentAssistPatch(start, { '/model': 'not-a-model' }, context), null);
+ assert.equal(applyAgentAssistPatch(start, { '/max_completion_tokens': 1.5 }, context), null);
+ assert.equal(applyAgentAssistPatch(start, { '/knowledge/web_sources': [{ url: 'ftp://example.com' }] }, context), null);
+ assert.equal(applyAgentAssistPatch(start, { '/azure_openai_gpt_key': 'x' }, context), null);
+}
+
+// An existing agent keeps its internal name when the display name changes.
+{
+ const existing = draft({ id: 'a-1', name: 'stable_name' });
+ const next = applyAgentAssistPatch(existing, { '/display_name': 'New name' }, { ...context, isNew: false });
+ assert.equal(next.name, 'stable_name');
+}
+
+console.log('agent editor assist logic: ok');
diff --git a/ui_tests/test_v2_agent_action_ask_ai.py b/ui_tests/test_v2_agent_action_ask_ai.py
new file mode 100644
index 000000000..b6b442071
--- /dev/null
+++ b/ui_tests/test_v2_agent_action_ask_ai.py
@@ -0,0 +1,231 @@
+# test_v2_agent_action_ask_ai.py
+"""
+Offline real-bundle browser regressions for Ask AI in the agent and action editors.
+Version: 0.261.288
+Implemented in: 0.261.288
+
+Covers the agent editor applying a proposal to the unsaved draft with Undo, an agent turn that
+drafts a new action that blocks saving until the person finishes it in the action editor and is
+created only through a save, the action editor keeping
+stored credentials out of the request, and both toggles hiding Ask AI when the admin turns it off.
+
+`POST /api/agents/assist` and `POST /api/actions/assist` are answered in the page by the real
+`run_editor_assist` pipeline with a scripted model, so no live model or Azure service is contacted.
+Reuses the closed My Workspace authoring harness and its local production assets. Run with
+PLAYWRIGHT_SERVICE_URL='' after building the V2 bundle.
+"""
+
+import copy
+import json
+import re
+import sys
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "application" / "single_app"))
+
+from functions_editor_assist import ( # noqa: E402
+ ChoiceHandles,
+ EditorAssistError,
+ EditorAssistServices,
+ parse_editor_assist_request,
+ run_editor_assist,
+)
+from ui_tests.fixtures.workspace_authoring import ( # noqa: E402
+ ACTION_ID,
+ AGENT_ID,
+ CREATED_ACTION_ID,
+ OWNER_ID,
+ STORED_KEY,
+ WorkspaceAuthoringFixture,
+ connect_options, # noqa: F401
+)
+
+
+pytestmark = pytest.mark.ui
+ASSIST_PATHS = {"/api/agents/assist": "agent", "/api/actions/assist": "action"}
+NEW_DESCRIPTION = "Reviews pull requests for security and style problems."
+NEW_ACTION_NAME = "Ticket lookup"
+
+
+class _Limiter:
+ def acquire(self, _user_id):
+ return object()
+
+ def release(self, _lease, refund=False):
+ return None
+
+
+class AskAiWorkspaceFixture(WorkspaceAuthoringFixture):
+ """The workspace authoring harness with both assist routes answered by the real pipeline."""
+
+ def __init__(self, page):
+ super().__init__(page)
+ self.agent_assist_enabled = True
+ self.action_assist_enabled = True
+ self.assist_requests = []
+ self.script = None
+
+ def _bootstrap(self):
+ payload = super()._bootstrap()
+ payload["features"]["enable_agent_ai_assistant"] = self.agent_assist_enabled
+ payload["features"]["enable_action_ai_assistant"] = self.action_assist_enabled
+ return payload
+
+ def _dispatch(self, route, entry):
+ kind = ASSIST_PATHS.get(entry.path)
+ if kind is None or entry.method != "POST":
+ super()._dispatch(route, entry)
+ return
+ body = copy.deepcopy(entry.body)
+ self.assist_requests.append((kind, copy.deepcopy(body)))
+ scope = body.pop("scope", "personal")
+ body.pop("group_id", None)
+ script = self.script
+
+ def call_model(_messages, _timeout):
+ request = parse_editor_assist_request(copy.deepcopy(body), kind=kind, scope=scope)
+ return json.dumps(script(request, ChoiceHandles(request))), "stop"
+
+ services = EditorAssistServices(limiter=_Limiter(), call_model=call_model)
+ try:
+ result = run_editor_assist(body, kind=kind, scope=scope, user_id=OWNER_ID, services=services)
+ except EditorAssistError as error:
+ self._json(route, error.payload(), error.status)
+ return
+ self._json(route, result)
+
+
+@pytest.fixture
+def ask_ai_ui(page):
+ fixture = AskAiWorkspaceFixture(page)
+ yield fixture
+ fixture.assert_clean()
+
+
+def ask(page, instruction):
+ page.get_by_role("button", name="Ask AI", exact=True).click()
+ panel = page.get_by_role("complementary", name="Ask AI", exact=True)
+ expect(panel).to_be_visible()
+ panel.get_by_role("textbox", name="Message Ask AI", exact=True).fill(instruction)
+ panel.get_by_role("button", name="Send to Ask AI", exact=True).click()
+ expect(panel.get_by_role("list", name="Changes in this turn")).to_be_visible()
+ return panel
+
+
+def test_agent_ask_ai_applies_to_draft_and_undo_restores_it(ask_ai_ui):
+ ui, page = ask_ai_ui, ask_ai_ui.page
+ original = ui.agents[AGENT_ID]["description"]
+ ui.script = lambda _request, _handles: {
+ "reply": "I rewrote the description.",
+ "operations": [{"op": "set", "path": "/description", "value": NEW_DESCRIPTION}],
+ }
+ ui.open(f"/workspace/agents/{AGENT_ID}")
+ description = page.get_by_label("Description", exact=True)
+ expect(description).to_have_value(original)
+
+ panel = ask(page, "Make the description about pull request reviews.")
+ expect(description).to_have_value(NEW_DESCRIPTION)
+ expect(panel.get_by_text("I rewrote the description.")).to_be_visible()
+ assert not ui.editor_writes, "Ask AI must never save the agent."
+
+ kind, body = ui.assist_requests[-1]
+ assert kind == "agent"
+ assert body["instruction"] == "Make the description about pull request reviews."
+ assert body.get("scope", "personal") == "personal"
+
+ panel.get_by_role("button", name=re.compile(r"^Undo this change")).click()
+ expect(description).to_have_value(original)
+ assert not ui.editor_writes
+
+
+def test_agent_ask_ai_drafts_an_action_created_only_on_save(ask_ai_ui):
+ ui, page = ask_ai_ui, ask_ai_ui.page
+
+ def script(request, _handles):
+ assert request.new_items is not None, "The agent editor must offer new actions."
+ assert "fixture_custom" in {option["value"] for option in request.new_items.type_options}
+ assert "agent" not in {option["value"] for option in request.new_items.type_options}
+ return {
+ "reply": "I drafted a ticket lookup action and assigned it.",
+ "operations": [{
+ "op": "create_item", "handle": "N1", "type": "fixture_custom",
+ "values": {"/displayName": NEW_ACTION_NAME, "/description": "Look up support tickets."},
+ }],
+ }
+
+ ui.script = script
+ ui.open(f"/workspace/agents/{AGENT_ID}")
+ ask(page, "Add an action that looks up support tickets.")
+ pending = page.get_by_role("list", name="New actions from Ask AI", exact=True)
+ expect(pending.get_by_role("listitem")).to_have_count(1)
+ expect(pending).to_contain_text(NEW_ACTION_NAME)
+ expect(pending.get_by_role("button", name=f"Finish {NEW_ACTION_NAME} in the action editor")).to_be_visible()
+ expect(pending.get_by_role("button", name=f"Remove new action {NEW_ACTION_NAME}")).to_be_visible()
+ expect(pending.get_by_text("Needs attention before saving: Endpoint is required.")).to_be_visible()
+ assert not ui.editor_writes, "A drafted action must not be created before the agent is saved."
+
+ # Saving is blocked until the drafted action is complete, and nothing is written.
+ page.get_by_role("button", name="Save agent", exact=True).click()
+ expect(page.get_by_text(re.compile("Endpoint is required")).first).to_be_visible()
+ assert not ui.editor_writes
+
+ # The person finishes it in the action editor, which returns to the agent with it assigned.
+ pending.get_by_role("button", name=f"Finish {NEW_ACTION_NAME} in the action editor").click()
+ expect(page).to_have_url(re.compile(r"/v2/workspace/actions/new\?"))
+ expect(page.get_by_label(re.compile(r"^Action name(?:\s*\*)?\s*$"))).to_have_value(NEW_ACTION_NAME)
+ page.get_by_role("navigation", name="Editor sections", exact=True).get_by_role(
+ "button", name="Configuration", exact=True
+ ).click()
+ page.get_by_label(re.compile(r"^Endpoint(?:\s*\*)?\s*$")).fill("https://tickets.example.test/api")
+ with page.expect_response(lambda response: response.request.method == "POST"
+ and response.url.endswith("/api/user/plugins?view=editor")) as created:
+ page.get_by_role("button", name="Save action", exact=True).click()
+ assert created.value.status == 201
+ expect(page).to_have_url(re.compile(rf"/v2/workspace/agents/{AGENT_ID}$"))
+ expect(page.get_by_role("list", name="New actions from Ask AI", exact=True)).to_have_count(0)
+ assert ui.actions[CREATED_ACTION_ID]["displayName"] == NEW_ACTION_NAME
+ assert ui.actions[CREATED_ACTION_ID]["type"] == "fixture_custom"
+
+ with page.expect_response(lambda response: response.request.method == "PATCH"
+ and response.url.endswith(f"/api/user/agents/{AGENT_ID}?view=editor")) as saved:
+ page.get_by_role("button", name="Save agent", exact=True).click()
+ assert saved.value.ok
+ assert [(write.method, write.path) for write in ui.editor_writes] == [
+ ("POST", "/api/user/plugins"), ("PATCH", f"/api/user/agents/{AGENT_ID}"),
+ ]
+ assert CREATED_ACTION_ID in ui.agents[AGENT_ID]["actions_to_load"]
+ assert not any(str(item).startswith("new:") for item in ui.agents[AGENT_ID]["actions_to_load"])
+
+
+def test_action_ask_ai_never_sends_stored_credentials(ask_ai_ui):
+ ui, page = ask_ai_ui, ask_ai_ui.page
+ ui.script = lambda _request, _handles: {
+ "reply": "I clarified when agents should call this action.",
+ "operations": [{"op": "set", "path": "/description", "value": "List approved workspace items."}],
+ }
+ ui.open(f"/workspace/actions/{ACTION_ID}")
+ ask(page, "Explain when an agent should call this.")
+ expect(page.get_by_label("Description", exact=True)).to_have_value("List approved workspace items.")
+ assert not ui.editor_writes, "Ask AI must never save the action."
+
+ kind, body = ui.assist_requests[-1]
+ assert kind == "action"
+ serialized = json.dumps(body)
+ assert STORED_KEY not in serialized
+ assert '"/auth/key"' not in serialized
+
+
+@pytest.mark.parametrize("path", [f"/workspace/agents/{AGENT_ID}", f"/workspace/actions/{ACTION_ID}"])
+def test_ask_ai_is_hidden_when_the_admin_turns_it_off(ask_ai_ui, path):
+ ui, page = ask_ai_ui, ask_ai_ui.page
+ ui.agent_assist_enabled = False
+ ui.action_assist_enabled = False
+ ui.open(path)
+ expect(page.get_by_label("Description", exact=True)).to_be_visible()
+ expect(page.get_by_role("button", name="Ask AI", exact=True)).to_have_count(0)
+ assert not ui.assist_requests
diff --git a/ui_tests/test_v2_group_action_drafts.ts b/ui_tests/test_v2_group_action_drafts.ts
index 8da928cfe..2574ddf67 100644
--- a/ui_tests/test_v2_group_action_drafts.ts
+++ b/ui_tests/test_v2_group_action_drafts.ts
@@ -24,9 +24,12 @@
import assert from 'node:assert/strict';
import { useBootstrapStore } from '../application/v2_ui/src/stores/bootstrapStore';
import {
+ clearWorkspaceActionHandoff,
clearWorkspaceEditorDrafts,
queueCreatedWorkspaceAction,
+ seedNewWorkspaceActionDraft,
takeCreatedWorkspaceAction,
+ takeWorkspaceActionHandoff,
type EditorWorkspaceScope,
} from '../application/v2_ui/src/lib/workspaceEditorDrafts';
@@ -101,10 +104,31 @@ function testAnInvalidReturnPathIsRejected(): void {
'A group agent path must not seed a personal handoff.');
}
+function testDraftedActionHandoffStaysInItsScope(): void {
+ seedOwner('owner-4');
+ clearWorkspaceEditorDrafts();
+ // Finishing an Ask AI drafted action records which placeholder the saved action replaces.
+ seedNewWorkspaceActionDraft(GROUP_RETURN_PATH, action('drafted'), action(''), GROUP_A, 'new:N1');
+ assert.equal(takeWorkspaceActionHandoff(GROUP_RETURN_PATH, GROUP_B), '',
+ 'A group A drafted-action handoff must never be taken from group B.');
+ assert.equal(takeWorkspaceActionHandoff(GROUP_RETURN_PATH, PERSONAL), '',
+ 'A group A drafted-action handoff must never be taken from personal scope.');
+ assert.equal(takeWorkspaceActionHandoff(GROUP_RETURN_PATH, GROUP_A), 'new:N1',
+ 'The group A drafted-action handoff must be taken from group A.');
+ assert.equal(takeWorkspaceActionHandoff(GROUP_RETURN_PATH, GROUP_A), '',
+ 'A drafted-action handoff must be taken exactly once.');
+ // Starting an unrelated new action forgets the placeholder so it is not replaced by mistake.
+ seedNewWorkspaceActionDraft(GROUP_RETURN_PATH, action('drafted'), action(''), GROUP_A, 'new:N2');
+ clearWorkspaceActionHandoff(GROUP_RETURN_PATH, GROUP_A);
+ assert.equal(takeWorkspaceActionHandoff(GROUP_RETURN_PATH, GROUP_A), '',
+ 'A cleared drafted-action handoff must not be taken.');
+}
+
for (const check of [
testGroupDraftNeverLeaksToAnotherGroupOrPersonal,
testPersonalHandoffIsUnchangedByTheScopeArgument,
testAnInvalidReturnPathIsRejected,
+ testDraftedActionHandoffStaysInItsScope,
]) {
check();
}