diff --git a/application/single_app/config.py b/application/single_app/config.py index 9340aec68..534b9a812 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.213" +VERSION = "0.250.214" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 04d4f58df..30a967da0 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -64,6 +64,14 @@ AGENT_INSTRUCTION_FIELD_LIMIT = 6000 AGENT_INSTRUCTION_OUTPUT_TOKEN_LIMIT = 1400 +AGENT_INSTRUCTION_CONTEXT_ITEM_LIMIT = 40 +AGENT_INSTRUCTION_CONTEXT_CAPABILITY_LIMIT = 30 +AGENT_INSTRUCTION_CONTEXT_LABEL_LIMIT = 200 +AGENT_INSTRUCTION_CONTEXT_DESCRIPTION_LIMIT = 400 +# Per-item caps alone still allow a very large prompt, so the rendered context +# blocks also share a single total character budget. +AGENT_INSTRUCTION_CONTEXT_TOTAL_LIMIT = 8000 +AGENT_INSTRUCTION_CONTEXT_TRUNCATION_NOTE = '- (additional items omitted)' def _redact_catalog_agent_instructions(catalog): @@ -142,18 +150,202 @@ def _create_agent_instruction_client(settings): ) -def _build_agent_instruction_messages(display_name, description, brief, existing_instructions): +def _truncate_agent_instruction_context_block(block, budget): + """Trim a rendered context block to fit the shared character budget. + + Per-item caps bound each entry, but not how many entries arrive, so the + combined blocks also share a total budget before they reach the model. + """ + if not block: + return '' + if len(block) <= budget: + return block + + lines = block.split('\n') + header = lines[0] + note = AGENT_INSTRUCTION_CONTEXT_TRUNCATION_NOTE + if len(header) + len(note) + 1 > budget: + return '' + + kept = [header] + used = len(header) + for line in lines[1:]: + projected = used + 1 + len(line) + if projected + len(note) + 1 > budget: + break + kept.append(line) + used = projected + + kept.append(note) + return '\n'.join(kept) + + +def _apply_agent_instruction_context_budget(actions_context, knowledge_context): + """Bound the combined size of the rendered action and knowledge blocks.""" + total_budget = AGENT_INSTRUCTION_CONTEXT_TOTAL_LIMIT + if len(actions_context) + len(knowledge_context) <= total_budget: + return actions_context, knowledge_context + + # Each block is guaranteed half the budget, and any headroom the other block + # does not need rolls over. + half_budget = total_budget // 2 + actions_budget = half_budget + max(0, half_budget - len(knowledge_context)) + trimmed_actions = _truncate_agent_instruction_context_block(actions_context, actions_budget) + knowledge_budget = total_budget - len(trimmed_actions) + trimmed_knowledge = _truncate_agent_instruction_context_block(knowledge_context, knowledge_budget) + return trimmed_actions, trimmed_knowledge + + +def _format_agent_instruction_token_value(value): + """Render a reference token value, quoting it when it contains separators.""" + normalized_value = _normalize_agent_instruction_draft_input( + value, + AGENT_INSTRUCTION_CONTEXT_LABEL_LIMIT, + ) + if not normalized_value: + return '' + if any(character in normalized_value for character in (' ', ':', '"')): + return '"{0}"'.format(normalized_value.replace('"', "'")) + return normalized_value + + +def _format_agent_instruction_actions_context(selected_actions): + """Summarize the client-supplied selected actions for the drafting prompt. + + The payload is untrusted display context only. It is normalized, truncated, + and bounded here so it can never be used to widen authorization or to bloat + the prompt. + """ + if not isinstance(selected_actions, list): + return '' + + lines = [] + for action in selected_actions[:AGENT_INSTRUCTION_CONTEXT_ITEM_LIMIT]: + if not isinstance(action, dict): + continue + label = _normalize_agent_instruction_draft_input( + action.get('display_name') or action.get('name'), + AGENT_INSTRUCTION_CONTEXT_LABEL_LIMIT, + ) + if not label: + continue + + token_value = _format_agent_instruction_token_value(label) + description = _normalize_agent_instruction_draft_input( + action.get('description'), + AGENT_INSTRUCTION_CONTEXT_DESCRIPTION_LIMIT, + ) + action_type = _normalize_agent_instruction_draft_input(action.get('type'), 100) + + detail = f'- #action:{token_value}' + if action_type: + detail += f' (type: {action_type})' + if description: + detail += f' — {description}' + lines.append(detail) + + capabilities = action.get('capabilities') + if not isinstance(capabilities, list): + continue + for capability in capabilities[:AGENT_INSTRUCTION_CONTEXT_CAPABILITY_LIMIT]: + if not isinstance(capability, dict): + continue + capability_key = _normalize_agent_instruction_draft_input( + capability.get('key'), + AGENT_INSTRUCTION_CONTEXT_LABEL_LIMIT, + ) + if not capability_key: + continue + capability_label = _normalize_agent_instruction_draft_input( + capability.get('label'), + AGENT_INSTRUCTION_CONTEXT_LABEL_LIMIT, + ) + capability_token = _format_agent_instruction_token_value(capability_key) + capability_line = f' - #action:{token_value}:{capability_token}' + if capability_label: + capability_line += f' — {capability_label}' + lines.append(capability_line) + + if not lines: + return '' + return 'Actions selected for this agent (reference only these):\n' + '\n'.join(lines) + + +def _format_agent_instruction_knowledge_context(assigned_knowledge): + """Summarize the client-supplied assigned knowledge for the drafting prompt.""" + if not isinstance(assigned_knowledge, dict) or not assigned_knowledge.get('enabled'): + return '' + + lines = [] + + def _append_entries(entries, token_type, label_keys, detail_keys=()): + if not isinstance(entries, list): + return + for entry in entries[:AGENT_INSTRUCTION_CONTEXT_ITEM_LIMIT]: + if isinstance(entry, dict): + label = next( + (entry.get(key) for key in label_keys if str(entry.get(key) or '').strip()), + '', + ) + detail = next( + (entry.get(key) for key in detail_keys if str(entry.get(key) or '').strip()), + '', + ) + else: + label = entry + detail = '' + + token_value = _format_agent_instruction_token_value(label) + if not token_value: + continue + + line = f'- #knowledge:{token_type}:{token_value}' + normalized_detail = _normalize_agent_instruction_draft_input( + detail, + AGENT_INSTRUCTION_CONTEXT_DESCRIPTION_LIMIT, + ) + if normalized_detail: + line += f' — {normalized_detail}' + lines.append(line) + + _append_entries(assigned_knowledge.get('sources'), 'workspace', ('name', 'id'), ('scope',)) + _append_entries(assigned_knowledge.get('documents'), 'doc', ('title', 'file_name'), ('source_name',)) + _append_entries(assigned_knowledge.get('tags'), 'tag', ('name', 'tag')) + _append_entries(assigned_knowledge.get('web_sources'), 'web', ('url',), ('mode_label', 'mode')) + + if not lines: + return '' + return 'Assigned knowledge available to this agent (reference only these):\n' + '\n'.join(lines) + + +def _build_agent_instruction_messages( + display_name, + description, + brief, + existing_instructions, + *, + selected_actions=None, + assigned_knowledge=None, +): """Build the prompt for drafting editable SimpleChat agent instructions.""" display_name = _normalize_agent_instruction_draft_input(display_name, 500) description = _normalize_agent_instruction_draft_input(description) brief = _normalize_agent_instruction_draft_input(brief) existing_instructions = _normalize_agent_instruction_draft_input(existing_instructions) + actions_context = _format_agent_instruction_actions_context(selected_actions) + knowledge_context = _format_agent_instruction_knowledge_context(assigned_knowledge) + actions_context, knowledge_context = _apply_agent_instruction_context_budget( + actions_context, + knowledge_context, + ) user_sections = [ f'Agent display name: {display_name or "Not provided"}', f'Agent description: {description or "Not provided"}', f'Author brief: {brief or "Not provided"}', ] + user_sections.append(actions_context or 'Actions selected for this agent: None') + user_sections.append(knowledge_context or 'Assigned knowledge available to this agent: None') if existing_instructions: user_sections.append(f'Existing instructions to improve or preserve where useful:\n{existing_instructions}') @@ -164,7 +356,21 @@ def _build_agent_instruction_messages(display_name, description, brief, existing 'You write production-ready SimpleChat agent instructions. ' 'Return only the finished instructions in Markdown. ' 'Be specific about role, goals, workflow, boundaries, tool use, and response style. ' - 'Do not include code fences, preambles, or commentary about how the instructions were created.' + 'Do not include code fences, preambles, or commentary about how the instructions were created.\n\n' + 'SimpleChat instructions support inline reference tokens that point at the actions and ' + 'knowledge already configured for this agent. Use them wherever the instructions explain ' + 'when or why to use a capability or a document:\n' + ' #action: references a selected action.\n' + ' #action:: references one enabled capability of that action.\n' + ' #knowledge:doc: references an assigned document.\n' + ' #knowledge:workspace: references an assigned workspace.\n' + ' #knowledge:tag: references an assigned tag limit.\n' + ' #knowledge:web: references an assigned web source.\n' + 'Wrap a value in double quotes when it contains a space or a colon, for example ' + '#knowledge:doc:"Employee Handbook.pdf".\n' + 'Only reference actions, capabilities, and knowledge items that appear in the provided lists. ' + 'Never invent a token for something that was not listed, and omit the tokens entirely when ' + 'no actions or knowledge were provided.' ), }, { @@ -713,6 +919,8 @@ def draft_agent_instructions(): description, brief, existing_instructions, + selected_actions=request_data.get('selected_actions'), + assigned_knowledge=request_data.get('assigned_knowledge'), ) response = client.chat.completions.create( **_build_agent_instruction_api_params(model_name, messages) diff --git a/application/single_app/static/js/agent_instruction_mentions.js b/application/single_app/static/js/agent_instruction_mentions.js new file mode 100644 index 000000000..c60712584 --- /dev/null +++ b/application/single_app/static/js/agent_instruction_mentions.js @@ -0,0 +1,921 @@ +// agent_instruction_mentions.js +// Progressive "#" reference autocomplete for the agent modal Instructions step. +// +// Authors reference the actions and assigned knowledge chosen in the earlier +// steps by typing "#". The menu drills down through namespace -> item -> +// capability and inserts a literal token that is saved with the instructions: +// +// #action: +// #action:: +// #knowledge:doc: +// #knowledge:workspace: +// #knowledge:tag: +// #knowledge:web: +// +// Values containing whitespace or a colon are wrapped in double quotes so the +// token stays unambiguous, e.g. #knowledge:doc:"Employee Handbook.pdf". +// +// The vendored SimpleMDE 1.11.2 bundle does not ship CodeMirror's show-hint +// addon, so the menu is rendered and positioned here instead. + +const MENTION_NAMESPACES = Object.freeze([ + { + key: 'action', + label: 'action', + description: 'Reference an action selected in the Actions step' + }, + { + key: 'knowledge', + label: 'knowledge', + description: 'Reference an assigned document, workspace, tag, or web source' + } +]); + +const MAX_VISIBLE_MENTION_ITEMS = 10; +// Characters that may sit immediately in front of a "#" for it to start a +// reference. Anything else (a letter, digit, etc.) means the "#" is mid-word. +const MENTION_BOUNDARY_CHARS = Object.freeze(new Set([' ', '\t', '\n', '\r', '(', '[', '{', '>', '"', "'", '`'])); +// Cap how far back the trigger scan looks so typing in a long instruction stays +// cheap. +const MENTION_LOOKBEHIND_LIMIT = 500; +// Knowledge items are grouped by type (documents first, since they are the +// common case) and sorted alphabetically inside each group. +const KNOWLEDGE_TYPE_ORDER = Object.freeze(['document', 'workspace', 'tag', 'web']); +// Namespaces whose query may span unquoted spaces, because document titles and +// workspace names routinely contain them. +const SPACE_TOLERANT_NAMESPACES = Object.freeze(new Set(['action', 'knowledge'])); + +/** + * Locate the "#" that starts the reference under the caret. + * + * This is a single linear reverse scan rather than a regular expression on + * purpose: a pattern able to describe optional quoted runs is ambiguous, and + * backtracking over ordinary instruction prose degrades exponentially. The scan + * is O(n) over a bounded window and runs on every keystroke. + * + * @returns {{ body: string, hasUnquotedWhitespace: boolean } | null} + */ +export function locateMentionTrigger(textBeforeCursor) { + // Keeping one extra character means a truncated window still exposes the + // real character in front of a "#", so word-boundary checks stay honest. + const window_ = String(textBeforeCursor ?? '').slice(-(MENTION_LOOKBEHIND_LIMIT + 1)); + + for (let index = window_.length - 1; index >= 0; index -= 1) { + const character = window_[index]; + + // A reference never spans lines, so stop at the start of this one. + if (character === '\n' || character === '\r') { + return null; + } + + if (character !== '#') { + continue; + } + + const precedingCharacter = index === 0 ? '' : window_[index - 1]; + if (index !== 0 && !MENTION_BOUNDARY_CHARS.has(precedingCharacter)) { + // The nearest "#" is mid-word, so there is no reference here. + return null; + } + + const body = window_.slice(index + 1); + return { body, hasUnquotedWhitespace: hasUnquotedWhitespace(body) }; + } + + return null; +} + +function hasUnquotedWhitespace(text) { + let inQuotes = false; + for (const character of String(text ?? '')) { + if (character === '"') { + inQuotes = !inQuotes; + continue; + } + if (!inQuotes && /\s/.test(character)) { + return true; + } + } + return false; +} + +/** + * Split a token body on colons while treating quoted runs as opaque, so a + * value such as "Q3: Results.pdf" stays in a single segment. + */ +export function splitTokenSegments(raw) { + const segments = []; + let current = ''; + let inQuotes = false; + + for (const character of String(raw ?? '')) { + if (character === '"') { + inQuotes = !inQuotes; + current += character; + continue; + } + if (character === ':' && !inQuotes) { + segments.push(current); + current = ''; + continue; + } + current += character; + } + + segments.push(current); + return segments; +} + +/** + * Quote a token value when it contains characters that would make the token + * boundary ambiguous. + */ +export function formatMentionValue(value) { + const text = String(value ?? '').trim(); + if (!text) { + return ''; + } + if (/[\s:"]/.test(text)) { + return `"${text.replaceAll('"', "'")}"`; + } + return text; +} + +export function buildActionToken(actionLabel, capabilityKey = '') { + const action = formatMentionValue(actionLabel); + if (!action) { + return ''; + } + const capability = formatMentionValue(capabilityKey); + return capability ? `#action:${action}:${capability}` : `#action:${action}`; +} + +export function buildKnowledgeToken(knowledgeType, value) { + const type = String(knowledgeType || '').trim().toLowerCase(); + const formattedValue = formatMentionValue(value); + if (!type || !formattedValue) { + return ''; + } + return `#knowledge:${type}:${formattedValue}`; +} + +function matchesQuery(text, query) { + const normalizedQuery = String(query ?? '').replaceAll('"', ''); + if (!normalizedQuery) { + return true; + } + return String(text || '').toLowerCase().includes(normalizedQuery.toLowerCase()); +} + +function compareByLabel(first, second) { + return String(first.label || '').localeCompare(String(second.label || ''), undefined, { sensitivity: 'base' }); +} + +/** + * Thin adapter so the controller can drive a plain textarea and a CodeMirror + * instance through the same interface. + */ +class TextareaAdapter { + constructor(textarea) { + this.textarea = textarea; + this.mirror = null; + } + + get element() { + return this.textarea; + } + + getTextBeforeCursor() { + return this.textarea.value.slice(0, this.textarea.selectionStart); + } + + replaceBeforeCursor(charactersToReplace, replacement) { + const cursor = this.textarea.selectionStart; + const start = Math.max(0, cursor - charactersToReplace); + const value = this.textarea.value; + this.textarea.value = `${value.slice(0, start)}${replacement}${value.slice(cursor)}`; + const nextCursor = start + replacement.length; + this.textarea.setSelectionRange(nextCursor, nextCursor); + this.textarea.dispatchEvent(new Event('input', { bubbles: true })); + } + + focus() { + this.textarea.focus(); + } + + on(eventName, handler) { + this.textarea.addEventListener(eventName, handler); + } + + off(eventName, handler) { + this.textarea.removeEventListener(eventName, handler); + } + + /** + * Measure the caret position by mirroring the textarea into a hidden div + * that shares its typography and padding. + */ + getCaretViewportCoords() { + const rect = this.textarea.getBoundingClientRect(); + const computed = window.getComputedStyle(this.textarea); + + if (!this.mirror) { + this.mirror = document.createElement('div'); + this.mirror.className = 'agent-mention-caret-mirror'; + document.body.appendChild(this.mirror); + } + + const mirrorStyle = this.mirror.style; + [ + 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'letterSpacing', + 'lineHeight', 'textTransform', 'wordSpacing', 'paddingTop', 'paddingRight', + 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', + 'borderBottomWidth', 'borderLeftWidth', 'boxSizing' + ].forEach(property => { + mirrorStyle[property] = computed[property]; + }); + mirrorStyle.width = `${this.textarea.clientWidth}px`; + + this.mirror.textContent = this.getTextBeforeCursor(); + const marker = document.createElement('span'); + marker.textContent = '\u200b'; + this.mirror.appendChild(marker); + + const markerRect = marker.getBoundingClientRect(); + const mirrorRect = this.mirror.getBoundingClientRect(); + + return { + left: rect.left + (markerRect.left - mirrorRect.left), + top: rect.top + (markerRect.top - mirrorRect.top) - this.textarea.scrollTop, + bottom: rect.top + (markerRect.bottom - mirrorRect.top) - this.textarea.scrollTop + }; + } + + destroy() { + if (this.mirror) { + this.mirror.remove(); + this.mirror = null; + } + } + + releaseAttachmentFlag() { + delete this.textarea.dataset.agentMentionsAttached; + } +} + +class CodeMirrorAdapter { + constructor(codemirror) { + this.codemirror = codemirror; + } + + get element() { + return this.codemirror.getWrapperElement(); + } + + getTextBeforeCursor() { + const cursor = this.codemirror.getCursor(); + return this.codemirror.getRange({ line: 0, ch: 0 }, cursor); + } + + replaceBeforeCursor(charactersToReplace, replacement) { + const cursor = this.codemirror.getCursor(); + const from = { line: cursor.line, ch: Math.max(0, cursor.ch - charactersToReplace) }; + this.codemirror.replaceRange(replacement, from, cursor); + } + + focus() { + this.codemirror.focus(); + } + + on(eventName, handler) { + this.codemirror.on(eventName, handler); + } + + off(eventName, handler) { + this.codemirror.off(eventName, handler); + } + + getCaretViewportCoords() { + const coords = this.codemirror.cursorCoords(true, 'window'); + return { left: coords.left, top: coords.top, bottom: coords.bottom }; + } + + destroy() { + // CodeMirror owns its DOM; nothing extra to clean up here. + } + + releaseAttachmentFlag() { + delete this.codemirror.__agentMentionsAttached; + } +} + +export class AgentInstructionMentions { + /** + * @param {object} options + * @param {() => Array} options.getActions Selected actions with capabilities. + * @param {() => object} options.getKnowledge Assigned knowledge reference. + * @param {() => void} [options.onBeforeOpen] Hook to refresh source data. + */ + constructor(options = {}) { + this.getActions = typeof options.getActions === 'function' ? options.getActions : () => []; + this.getKnowledge = typeof options.getKnowledge === 'function' ? options.getKnowledge : () => ({}); + this.onBeforeOpen = typeof options.onBeforeOpen === 'function' ? options.onBeforeOpen : null; + + this.menu = null; + this.listElement = null; + this.emptyElement = null; + this.items = []; + this.activeIndex = 0; + this.activeAdapter = null; + this.triggerLength = 0; + this.attachments = []; + + this.handleDocumentPointerDown = this.handleDocumentPointerDown.bind(this); + this.handleWindowResize = this.handleWindowResize.bind(this); + } + + attachTextarea(textarea) { + if (!textarea || textarea.dataset.agentMentionsAttached === 'true') { + return; + } + textarea.dataset.agentMentionsAttached = 'true'; + this.attachAdapter(new TextareaAdapter(textarea), 'keydown', [ + // Text actually changed, so a reference may be starting. + { name: 'input', allowOpen: true }, + // Caret moved without editing: only re-evaluate an open menu. + { name: 'keyup', allowOpen: false }, + { name: 'click', allowOpen: false } + ]); + } + + attachCodeMirror(codemirror) { + if (!codemirror || codemirror.__agentMentionsAttached) { + return; + } + codemirror.__agentMentionsAttached = true; + this.attachAdapter(new CodeMirrorAdapter(codemirror), 'keydown', [ + // "changes" covers typing and deleting; "inputRead" alone would + // miss backspacing through a reference. + { name: 'changes', allowOpen: true }, + { name: 'cursorActivity', allowOpen: false } + ]); + } + + attachAdapter(adapter, keydownEvent, inputEvents) { + const onKeydown = (first, second) => { + // CodeMirror passes (instance, event); a textarea passes (event). + const event = second || first; + this.handleKeydown(adapter, event); + }; + const onBlur = () => { + window.setTimeout(() => { + if (this.activeAdapter === adapter && !this.isPointerInsideMenu) { + this.close(); + } + }, 150); + }; + + adapter.on(keydownEvent, onKeydown); + adapter.on('blur', onBlur); + + const inputHandlers = inputEvents.map(({ name, allowOpen }) => { + const handler = () => { + window.setTimeout(() => this.handleInput(adapter, { allowOpen }), 0); + }; + adapter.on(name, handler); + return { name, handler }; + }); + + this.attachments.push({ adapter, keydownEvent, onKeydown, onBlur, inputHandlers }); + } + + ensureMenu() { + if (this.menu) { + return this.menu; + } + + const menu = document.createElement('div'); + menu.className = 'agent-mention-menu d-none'; + menu.setAttribute('role', 'listbox'); + menu.setAttribute('aria-label', 'Insert agent reference'); + + const header = document.createElement('div'); + header.className = 'agent-mention-menu-header'; + header.id = 'agent-mention-menu-header'; + menu.appendChild(header); + + const list = document.createElement('div'); + list.className = 'agent-mention-menu-list'; + menu.appendChild(list); + + const empty = document.createElement('div'); + empty.className = 'agent-mention-menu-empty d-none'; + menu.appendChild(empty); + + menu.addEventListener('mouseenter', () => { + this.isPointerInsideMenu = true; + }); + menu.addEventListener('mouseleave', () => { + this.isPointerInsideMenu = false; + }); + menu.addEventListener('mousedown', event => { + // Keep focus in the editor so the insertion point survives the click. + event.preventDefault(); + }); + + document.body.appendChild(menu); + this.menu = menu; + this.headerElement = header; + this.listElement = list; + this.emptyElement = empty; + return menu; + } + + get isOpen() { + return Boolean(this.menu) && !this.menu.classList.contains('d-none'); + } + + /** + * Parse the text immediately before the caret into a menu request. + * Returns null when the caret is not inside a "#" reference. + */ + parseTrigger(textBeforeCursor) { + const located = locateMentionTrigger(textBeforeCursor); + if (!located) { + return null; + } + + const segments = splitTokenSegments(located.body); + + if (located.hasUnquotedWhitespace) { + // Only a typed namespace may span spaces, and such a match is only + // trusted while it still resolves to at least one item. + if (segments.length === 1) { + return null; + } + if (!SPACE_TOLERANT_NAMESPACES.has(segments[0].trim().toLowerCase())) { + return null; + } + } + + return this.parseTriggerBody(located.body, segments, located.hasUnquotedWhitespace); + } + + parseTriggerBody(raw, segments, allowsSpaces) { + const triggerLength = raw.length + 1; + + if (segments.length === 1) { + return { level: 'namespace', query: raw, triggerLength, allowsSpaces }; + } + + const [namespace, ...rest] = segments; + const normalizedNamespace = namespace.trim().toLowerCase(); + + if (normalizedNamespace === 'knowledge') { + // Only the first segment after the namespace is a filter; once a + // type prefix is present the token is already complete. + if (rest.length > 1) { + return null; + } + return { level: 'knowledge', query: rest[0] || '', triggerLength, allowsSpaces }; + } + + if (normalizedNamespace === 'action') { + if (rest.length === 1) { + return { level: 'action', query: rest[0] || '', triggerLength, allowsSpaces }; + } + if (rest.length === 2) { + return { + level: 'capability', + actionLabel: rest[0] || '', + query: rest[1] || '', + triggerLength, + allowsSpaces + }; + } + return null; + } + + return null; + } + + buildNamespaceItems(query) { + return MENTION_NAMESPACES + .filter(namespace => matchesQuery(namespace.label, query)) + .map(namespace => ({ + label: namespace.label, + detail: namespace.description, + badge: '', + token: `#${namespace.key}:`, + keepOpen: true + })); + } + + buildActionItems(query) { + return (this.getActions() || []) + .map(action => { + const label = String(action.display_name || action.name || '').trim(); + return { + label, + detail: String(action.description || '').trim(), + badge: String(action.type || '').trim(), + hasCapabilities: Array.isArray(action.capabilities) && action.capabilities.length > 0, + token: buildActionToken(label) + }; + }) + .filter(item => item.label && matchesQuery(item.label, query)) + .sort(compareByLabel) + .map(item => (item.hasCapabilities + ? { ...item, token: `#action:${formatMentionValue(item.label)}:`, keepOpen: true } + : item)); + } + + buildCapabilityItems(actionLabel, query) { + const normalizedLabel = String(actionLabel || '').replaceAll('"', '').trim().toLowerCase(); + const action = (this.getActions() || []).find(candidate => { + const label = String(candidate.display_name || candidate.name || '').trim().toLowerCase(); + return label === normalizedLabel; + }); + + if (!action) { + return []; + } + + return (action.capabilities || []) + .filter(capability => matchesQuery(capability.key, query) || matchesQuery(capability.label, query)) + .map(capability => ({ + label: capability.key, + detail: capability.label, + badge: 'capability', + token: buildActionToken(action.display_name || action.name, capability.key) + })); + } + + buildKnowledgeItems(query) { + const knowledge = this.getKnowledge() || {}; + if (!knowledge.enabled) { + return []; + } + + const items = []; + + (knowledge.documents || []).forEach(document_ => { + const label = String(document_.title || document_.file_name || '').trim(); + if (!label) { + return; + } + items.push({ + label, + detail: String(document_.source_name || '').trim(), + badge: 'document', + token: buildKnowledgeToken('doc', label) + }); + }); + + (knowledge.sources || []).forEach(source => { + const label = String(source.name || '').trim(); + if (!label) { + return; + } + items.push({ + label, + detail: `${source.scope || ''} workspace`.trim(), + badge: 'workspace', + token: buildKnowledgeToken('workspace', label) + }); + }); + + (knowledge.tags || []).forEach(tag => { + const label = String(tag || '').trim(); + if (!label) { + return; + } + items.push({ + label, + detail: 'Tag limit', + badge: 'tag', + token: buildKnowledgeToken('tag', label) + }); + }); + + (knowledge.web_sources || []).forEach(webSource => { + const label = String(webSource.url || '').trim(); + if (!label) { + return; + } + items.push({ + label, + detail: String(webSource.mode_label || '').trim(), + badge: 'web', + token: buildKnowledgeToken('web', label) + }); + }); + + return items + .filter(item => matchesQuery(item.label, query) || matchesQuery(item.badge, query)) + .sort((first, second) => { + const typeDelta = KNOWLEDGE_TYPE_ORDER.indexOf(first.badge) - KNOWLEDGE_TYPE_ORDER.indexOf(second.badge); + return typeDelta !== 0 ? typeDelta : compareByLabel(first, second); + }); + } + + buildItems(trigger) { + switch (trigger.level) { + case 'namespace': + return this.buildNamespaceItems(trigger.query); + case 'action': + return this.buildActionItems(trigger.query); + case 'capability': + return this.buildCapabilityItems(trigger.actionLabel, trigger.query); + case 'knowledge': + return this.buildKnowledgeItems(trigger.query); + default: + return []; + } + } + + getEmptyMessage(level) { + switch (level) { + case 'action': + return 'No actions were selected in the Actions step.'; + case 'capability': + return 'This action has no enabled capabilities.'; + case 'knowledge': + return 'No assigned knowledge was selected in the Knowledge step.'; + default: + return 'No matching references.'; + } + } + + getHeaderText(level) { + switch (level) { + case 'action': + return 'Selected actions'; + case 'capability': + return 'Enabled capabilities'; + case 'knowledge': + return 'Assigned knowledge'; + default: + return 'Reference'; + } + } + + handleInput(adapter, { allowOpen = true } = {}) { + const isOpenForAdapter = this.isOpen && this.activeAdapter === adapter; + // Caret-only events may re-evaluate or close an open menu, but must + // never pop one open over text the author wrote earlier. + if (!allowOpen && !isOpenForAdapter) { + return; + } + + const trigger = this.parseTrigger(adapter.getTextBeforeCursor()); + if (!trigger) { + if (this.activeAdapter === adapter) { + this.close(); + } + return; + } + + if (this.onBeforeOpen) { + this.onBeforeOpen(); + } + + const items = this.buildItems(trigger); + // A space-spanning query is only a reference for as long as it still + // resolves; otherwise the author is just writing prose after a token. + if (trigger.allowsSpaces && !items.length) { + if (this.activeAdapter === adapter) { + this.close(); + } + return; + } + + this.activeAdapter = adapter; + this.triggerLength = trigger.triggerLength; + this.render(trigger, items); + } + + render(trigger, items) { + const menu = this.ensureMenu(); + const visibleItems = items.slice(0, MAX_VISIBLE_MENTION_ITEMS); + this.items = visibleItems; + this.activeIndex = 0; + + this.headerElement.textContent = this.getHeaderText(trigger.level); + this.listElement.textContent = ''; + + if (!visibleItems.length) { + this.emptyElement.textContent = this.getEmptyMessage(trigger.level); + this.emptyElement.classList.remove('d-none'); + } else { + this.emptyElement.classList.add('d-none'); + visibleItems.forEach((item, index) => { + this.listElement.appendChild(this.createItemElement(item, index)); + }); + if (items.length > visibleItems.length) { + const overflow = document.createElement('div'); + overflow.className = 'agent-mention-menu-overflow'; + overflow.textContent = `${items.length - visibleItems.length} more — keep typing to filter`; + this.listElement.appendChild(overflow); + } + } + + menu.classList.remove('d-none'); + this.updateActiveItem(); + this.position(); + + document.addEventListener('mousedown', this.handleDocumentPointerDown); + window.addEventListener('resize', this.handleWindowResize); + } + + createItemElement(item, index) { + const row = document.createElement('div'); + row.className = 'agent-mention-menu-item'; + row.id = `agent-mention-option-${index}`; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', 'false'); + + const main = document.createElement('div'); + main.className = 'agent-mention-menu-item-main'; + + const label = document.createElement('span'); + label.className = 'agent-mention-menu-item-label'; + label.textContent = item.label; + main.appendChild(label); + + if (item.badge) { + const badge = document.createElement('span'); + badge.className = 'badge text-bg-secondary agent-mention-menu-item-badge'; + badge.textContent = item.badge; + main.appendChild(badge); + } + + if (item.keepOpen) { + const chevron = document.createElement('i'); + chevron.className = 'bi bi-chevron-right agent-mention-menu-item-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + main.appendChild(chevron); + } + + row.appendChild(main); + + if (item.detail) { + const detail = document.createElement('div'); + detail.className = 'agent-mention-menu-item-detail'; + detail.textContent = item.detail; + row.appendChild(detail); + } + + row.addEventListener('mousemove', () => { + if (this.activeIndex !== index) { + this.activeIndex = index; + this.updateActiveItem(); + } + }); + row.addEventListener('click', () => this.select(index)); + + return row; + } + + updateActiveItem() { + const rows = Array.from(this.listElement.querySelectorAll('.agent-mention-menu-item')); + rows.forEach((row, index) => { + const isActive = index === this.activeIndex; + row.classList.toggle('active', isActive); + row.setAttribute('aria-selected', isActive ? 'true' : 'false'); + if (isActive) { + this.menu.setAttribute('aria-activedescendant', row.id); + row.scrollIntoView?.({ block: 'nearest' }); + } + }); + } + + position() { + if (!this.activeAdapter || !this.menu) { + return; + } + + const coords = this.activeAdapter.getCaretViewportCoords(); + const menuRect = this.menu.getBoundingClientRect(); + const margin = 8; + + let left = coords.left; + if (left + menuRect.width + margin > window.innerWidth) { + left = Math.max(margin, window.innerWidth - menuRect.width - margin); + } + + let top = coords.bottom + 4; + if (top + menuRect.height + margin > window.innerHeight) { + const above = coords.top - menuRect.height - 4; + top = above >= margin ? above : Math.max(margin, window.innerHeight - menuRect.height - margin); + } + + this.menu.style.left = `${Math.max(margin, left)}px`; + this.menu.style.top = `${top}px`; + } + + select(index) { + const item = this.items[index]; + if (!item || !this.activeAdapter || !item.token) { + return; + } + + const adapter = this.activeAdapter; + const replacement = item.keepOpen ? item.token : `${item.token} `; + adapter.replaceBeforeCursor(this.triggerLength, replacement); + adapter.focus(); + + if (item.keepOpen) { + // Advance to the next level immediately so the menu does not blink + // between the namespace, action, and capability lists. + this.triggerLength = item.token.length; + this.handleInput(adapter, { allowOpen: true }); + return; + } + + this.close(); + } + + handleKeydown(adapter, event) { + if (!this.isOpen || this.activeAdapter !== adapter || !event) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + if (this.items.length) { + this.activeIndex = (this.activeIndex + 1) % this.items.length; + this.updateActiveItem(); + } + break; + case 'ArrowUp': + event.preventDefault(); + if (this.items.length) { + this.activeIndex = (this.activeIndex - 1 + this.items.length) % this.items.length; + this.updateActiveItem(); + } + break; + case 'Tab': + case 'Enter': + if (this.items.length) { + event.preventDefault(); + if (typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + this.select(this.activeIndex); + } + break; + case 'Escape': + event.preventDefault(); + this.close(); + break; + default: + break; + } + } + + handleDocumentPointerDown(event) { + if (this.menu && !this.menu.contains(event.target)) { + this.close(); + } + } + + handleWindowResize() { + if (this.isOpen) { + this.position(); + } + } + + close() { + if (this.menu) { + this.menu.classList.add('d-none'); + this.menu.removeAttribute('aria-activedescendant'); + } + this.items = []; + this.activeIndex = 0; + this.activeAdapter = null; + this.isPointerInsideMenu = false; + document.removeEventListener('mousedown', this.handleDocumentPointerDown); + window.removeEventListener('resize', this.handleWindowResize); + } + + destroy() { + this.close(); + this.attachments.forEach(({ adapter, keydownEvent, onKeydown, onBlur, inputHandlers }) => { + adapter.off(keydownEvent, onKeydown); + adapter.off('blur', onBlur); + inputHandlers.forEach(({ name, handler }) => adapter.off(name, handler)); + adapter.releaseAttachmentFlag(); + adapter.destroy(); + }); + this.attachments = []; + if (this.menu) { + this.menu.remove(); + this.menu = null; + } + } +} + +export const MENTION_MENU_MAX_VISIBLE_ITEMS = MAX_VISIBLE_MENTION_ITEMS; +export const AGENT_MENTION_NAMESPACES = MENTION_NAMESPACES; diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index 597958e2e..782ad5f3c 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -3,9 +3,23 @@ import { showToast } from "./chat/chat-toast.js"; import * as agentsCommon from "./agents_common.js"; import { getModelSupportedLevels } from "./chat/chat-reasoning.js"; +import { AgentInstructionMentions, buildActionToken, buildKnowledgeToken } from "./agent_instruction_mentions.js"; const ACTION_CAPABILITIES_KEY = 'action_capabilities'; const ASSIGNED_KNOWLEDGE_KEY = 'assigned_knowledge'; +// Ordered step keys for the agent modal. The index in this array is the step +// number rendered in the DOM (`#agent-step-1` ... `#agent-step-7`) and the +// position of the matching `.step-indicator`. Reordering steps means reordering +// this array plus the template markup, never sprinkling new magic numbers. +const AGENT_STEP_KEYS = Object.freeze([ + 'basic', + 'model', + 'actions', + 'knowledge', + 'instructions', + 'advanced', + 'summary' +]); const publicWorkspaceLowerSingular = window.getPublicWorkspaceLabel ? window.getPublicWorkspaceLabel('lower_singular') : 'public workspace'; const publicWorkspaceLowerPlural = window.getPublicWorkspaceLabel ? window.getPublicWorkspaceLabel('lower_plural') : 'public workspaces'; const ASSIGNED_KNOWLEDGE_USER_ACTIONS = Object.freeze(['search', 'analyze', 'compare']); @@ -221,7 +235,7 @@ const CHART_CAPABILITY_DEFINITIONS = [ export class AgentModalStepper { constructor(isAdmin = false, options = {}) { this.currentStep = 1; - this.maxSteps = 7; + this.maxSteps = AGENT_STEP_KEYS.length; this.isEditMode = false; this.isAdmin = isAdmin; // Track if this is admin context this.workspaceScope = options.workspaceScope || (isAdmin ? 'admin' : 'user'); @@ -234,6 +248,7 @@ export class AgentModalStepper { this.templateSubmitButton = document.getElementById('agent-modal-submit-template-btn'); this.foundryPlaceholderInstructions = 'Placeholder instructions: Azure AI Foundry agent manages its own prompt.'; this.instructionsEditor = null; + this.instructionMentions = null; this.foundryEndpoints = []; this.foundryAgents = []; this.assignedKnowledgeCatalog = { sources: [], documents: [], tags: [] }; @@ -287,7 +302,12 @@ export class AgentModalStepper { agentModal.addEventListener('shown.bs.modal', () => { this.initializeInstructionsEditor(); this.initializeVoiceControls(); - this.refreshInstructionsEditor(this.currentStep === 3 && !this.isAnyFoundryType()); + this.initializeInstructionMentions(); + this.refreshInstructionsEditor(this.isOnStep('instructions') && !this.isAnyFoundryType()); + }); + + agentModal.addEventListener('hidden.bs.modal', () => { + this.instructionMentions?.close(); }); } @@ -1524,6 +1544,199 @@ export class AgentModalStepper { }); } + initializeInstructionMentions() { + if (!this.instructionMentions) { + this.instructionMentions = new AgentInstructionMentions({ + getActions: () => this.getSelectedActionsWithCapabilities(), + getKnowledge: () => this.getAssignedKnowledgeReference() + }); + } + + const brief = document.getElementById('agent-instruction-brief'); + if (brief) { + this.instructionMentions.attachTextarea(brief); + } + + if (this.instructionsEditor?.codemirror) { + this.instructionMentions.attachCodeMirror(this.instructionsEditor.codemirror); + } else { + const instructionsInput = document.getElementById('agent-instructions'); + if (instructionsInput) { + this.instructionMentions.attachTextarea(instructionsInput); + } + } + } + + /** + * Entering the Instructions step: make sure the knowledge catalog is loaded, + * wire the "#" autocomplete, and refresh the reference panel so it reflects + * whatever was chosen in the Actions and Knowledge steps. + */ + prepareInstructionsContext() { + if (this.isAnyFoundryType()) { + this.renderInstructionsContextPanel(); + return; + } + + this.initializeInstructionMentions(); + + const knowledgeEnabled = Boolean(document.getElementById('agent-assigned-knowledge-enabled')?.checked); + if (knowledgeEnabled && !this.assignedKnowledgeCatalogLoaded) { + this.loadAssignedKnowledgeCatalog() + .then(() => this.renderInstructionsContextPanel()) + .catch(() => this.renderInstructionsContextPanel()); + return; + } + + this.renderInstructionsContextPanel(); + } + + createInstructionsContextItem(title, token, detail = '', badges = []) { + const item = document.createElement('div'); + item.className = 'agent-instructions-context-item'; + + const heading = document.createElement('div'); + heading.className = 'd-flex align-items-center gap-2 flex-wrap'; + + const titleEl = document.createElement('span'); + titleEl.className = 'agent-instructions-context-item-title'; + titleEl.textContent = title; + heading.appendChild(titleEl); + + badges.forEach(badgeText => { + const badge = document.createElement('span'); + badge.className = 'badge text-bg-secondary'; + badge.textContent = badgeText; + heading.appendChild(badge); + }); + + item.appendChild(heading); + + if (detail) { + const detailEl = document.createElement('div'); + detailEl.className = 'text-muted small'; + detailEl.textContent = detail; + item.appendChild(detailEl); + } + + if (token) { + const tokenEl = document.createElement('div'); + tokenEl.className = 'agent-instructions-context-item-token'; + tokenEl.textContent = token; + item.appendChild(tokenEl); + } + + return item; + } + + renderInstructionsContextPanel() { + const panel = document.getElementById('agent-instructions-context-panel'); + const actionsContainer = document.getElementById('agent-instructions-context-actions'); + const knowledgeContainer = document.getElementById('agent-instructions-context-knowledge'); + if (!panel || !actionsContainer || !knowledgeContainer) { + return; + } + + panel.classList.toggle('d-none', this.isAnyFoundryType()); + if (this.isAnyFoundryType()) { + return; + } + + const actions = this.getSelectedActionsWithCapabilities(); + const knowledge = this.getAssignedKnowledgeReference(); + + actionsContainer.textContent = ''; + if (!actions.length) { + const empty = document.createElement('div'); + empty.className = 'agent-instructions-context-empty'; + empty.textContent = 'No actions selected in the Actions step.'; + actionsContainer.appendChild(empty); + } else { + actions.forEach(action => { + const label = action.display_name || action.name; + const item = this.createInstructionsContextItem( + label, + buildActionToken(label), + action.description, + action.type ? [action.type] : [] + ); + + if (action.capabilities.length) { + const capabilityList = document.createElement('div'); + capabilityList.className = 'd-flex flex-wrap gap-1 mt-1'; + action.capabilities.forEach(capability => { + const badge = document.createElement('span'); + badge.className = 'badge text-bg-primary'; + badge.textContent = capability.key; + badge.title = capability.label; + capabilityList.appendChild(badge); + }); + item.appendChild(capabilityList); + } + + actionsContainer.appendChild(item); + }); + } + + knowledgeContainer.textContent = ''; + const knowledgeItems = []; + if (knowledge.enabled) { + knowledge.sources.forEach(source => { + knowledgeItems.push(this.createInstructionsContextItem( + source.name, + buildKnowledgeToken('workspace', source.name), + '', + ['workspace'] + )); + }); + knowledge.documents.forEach(documentItem => { + const label = documentItem.title || documentItem.file_name; + knowledgeItems.push(this.createInstructionsContextItem( + label, + buildKnowledgeToken('doc', label), + documentItem.source_name, + ['document'] + )); + }); + knowledge.tags.forEach(tag => { + knowledgeItems.push(this.createInstructionsContextItem( + tag, + buildKnowledgeToken('tag', tag), + '', + ['tag'] + )); + }); + knowledge.web_sources.forEach(webSource => { + knowledgeItems.push(this.createInstructionsContextItem( + webSource.url, + buildKnowledgeToken('web', webSource.url), + webSource.mode_label, + ['web'] + )); + }); + } + + if (!knowledgeItems.length) { + const empty = document.createElement('div'); + empty.className = 'agent-instructions-context-empty'; + empty.textContent = knowledge.enabled + ? 'No knowledge items resolved in the Knowledge step.' + : 'Assigned knowledge is turned off.'; + knowledgeContainer.appendChild(empty); + } else { + knowledgeItems.forEach(item => knowledgeContainer.appendChild(item)); + } + + const actionsCount = document.getElementById('agent-instructions-context-actions-count'); + if (actionsCount) { + actionsCount.textContent = `${actions.length} action${actions.length === 1 ? '' : 's'}`; + } + const knowledgeCount = document.getElementById('agent-instructions-context-knowledge-count'); + if (knowledgeCount) { + knowledgeCount.textContent = `${knowledgeItems.length} knowledge item${knowledgeItems.length === 1 ? '' : 's'}`; + } + } + getInstructionsValue() { if (this.instructionsEditor) { return this.instructionsEditor.value(); @@ -1691,7 +1904,7 @@ export class AgentModalStepper { const modelGroup = document.getElementById('agent-global-model-group'); const customToggle = document.getElementById('agent-custom-connection-toggle'); const customFields = document.getElementById('agent-custom-connection-fields'); - const actionsSection = document.getElementById('agent-step-4'); + const actionsSection = this.getStepElement('actions'); const actionsDisabled = document.getElementById('agent-actions-disabled'); const actionsContainer = document.getElementById('agent-actions-container'); const actionsHeader = actionsSection?.querySelector('.card'); @@ -1798,6 +2011,12 @@ export class AgentModalStepper { foundryModeNote.textContent = 'Classic Foundry agents run as the signed-in user through the SDK-backed invocation path.'; } } + + // Switching agent type while the Instructions step is open changes whether + // the reference panel and "#" autocomplete apply at all. + if (this.isOnStep('instructions')) { + this.prepareInstructionsContext(); + } } updateAgentTypeLock() { @@ -1939,6 +2158,7 @@ export class AgentModalStepper { this.updateGeneratedName(); this.initializeInstructionsEditor(); this.initializeVoiceControls(); + this.initializeInstructionMentions(); this.syncAgentTypeSelector(); this.applyAgentTypeVisibility(); this.updateAgentTypeLock(); @@ -2510,7 +2730,7 @@ export class AgentModalStepper { } async skipToEnd() { - // Skip to the summary step (step 6) + // Skip straight to the summary step //if (this.actionsToSelect != null && this.actionsToSelect.length > 0) { // this.setSelectedActions(this.actionsToSelect); //} @@ -2550,6 +2770,24 @@ export class AgentModalStepper { this.updateAgentTypeLock(); } + getStepKey(stepNumber = this.currentStep) { + return AGENT_STEP_KEYS[stepNumber - 1] || ''; + } + + getStepNumber(stepKey) { + const index = AGENT_STEP_KEYS.indexOf(stepKey); + return index === -1 ? 0 : index + 1; + } + + getStepElement(stepKey) { + const stepNumber = this.getStepNumber(stepKey); + return stepNumber ? document.getElementById(`agent-step-${stepNumber}`) : null; + } + + isOnStep(stepKey) { + return this.getStepKey() === stepKey; + } + showStep(stepNumber) { // Hide all steps for (let i = 1; i <= this.maxSteps; i++) { @@ -2565,50 +2803,55 @@ export class AgentModalStepper { currentStep.classList.remove('d-none'); } - if (stepNumber === 3) { - this.initializeInstructionsEditor(); - this.refreshInstructionsEditor(!this.isAnyFoundryType()); - } - - if (stepNumber === 2) { - const isFoundry = this.isAnyFoundryType(); - const customConnectionToggle = document.getElementById('agent-custom-connection-toggle'); - const modelGroup = document.getElementById('agent-global-model-group'); + switch (this.getStepKey(stepNumber)) { + case 'model': { + const isFoundry = this.isAnyFoundryType(); + const customConnectionToggle = document.getElementById('agent-custom-connection-toggle'); + const modelGroup = document.getElementById('agent-global-model-group'); + + if (customConnectionToggle) { + if (isFoundry) { + customConnectionToggle.classList.add('d-none'); + } else if (!this.isAdmin) { + const allowCustomEndpoints = this.workspaceScope === 'group' + ? appSettings?.allow_group_custom_endpoints + : appSettings?.allow_user_custom_endpoints; + customConnectionToggle.classList.toggle('d-none', !allowCustomEndpoints); + } else { + customConnectionToggle.classList.remove('d-none'); + } + } - if (customConnectionToggle) { - if (isFoundry) { - customConnectionToggle.classList.add('d-none'); - } else if (!this.isAdmin) { - const allowCustomEndpoints = this.workspaceScope === 'group' - ? appSettings?.allow_group_custom_endpoints - : appSettings?.allow_user_custom_endpoints; - customConnectionToggle.classList.toggle('d-none', !allowCustomEndpoints); - } else { - customConnectionToggle.classList.remove('d-none'); + if (modelGroup) { + modelGroup.classList.toggle('d-none', isFoundry); } + break; } - if (modelGroup) { - modelGroup.classList.toggle('d-none', isFoundry); - } - } - - // Load actions when reaching step 4 - if (stepNumber === 4) { - if (!this.isAnyFoundryType()) { - this.loadAvailableActions(); - } - } - - if (stepNumber === 5) { - if (document.getElementById('agent-assigned-knowledge-enabled')?.checked) { - this.loadAssignedKnowledgeCatalog(); - } - } + case 'actions': + if (!this.isAnyFoundryType()) { + this.loadAvailableActions(); + } + break; - // Populate summary when reaching step 7 - if (stepNumber === 7) { - this.populateSummary(); + case 'knowledge': + if (document.getElementById('agent-assigned-knowledge-enabled')?.checked) { + this.loadAssignedKnowledgeCatalog(); + } + break; + + case 'instructions': + this.initializeInstructionsEditor(); + this.prepareInstructionsContext(); + this.refreshInstructionsEditor(!this.isAnyFoundryType()); + break; + + case 'summary': + this.populateSummary(); + break; + + default: + break; } } @@ -2682,7 +2925,7 @@ export class AgentModalStepper { } } - // Skip button - show on steps 2-5, hide on first and last step + // Skip button - show on the intermediate steps, hide on first and last step if (skipBtn) { if (this.currentStep === 1 || this.currentStep === this.maxSteps) { skipBtn.classList.add('d-none'); @@ -2723,8 +2966,8 @@ export class AgentModalStepper { } validateCurrentStep() { - switch (this.currentStep) { - case 1: // Basic Info + switch (this.getStepKey()) { + case 'basic': { const displayName = document.getElementById('agent-display-name'); const description = document.getElementById('agent-description'); @@ -2740,8 +2983,9 @@ export class AgentModalStepper { return false; } break; + } - case 2: // Model & Connection + case 'model': if (this.isAnyFoundryType()) { const endpoint = document.getElementById('agent-foundry-endpoint'); const deployment = document.getElementById('agent-foundry-deployment'); @@ -2797,7 +3041,7 @@ export class AgentModalStepper { } break; - case 3: // Instructions + case 'instructions': { const instructionsValue = this.getInstructionsValue(); if (!this.isAnyFoundryType()) { if (!instructionsValue.trim()) { @@ -2812,24 +3056,25 @@ export class AgentModalStepper { } } break; + } - case 4: // Actions + case 'actions': if (!this.isAnyFoundryType()) { // Actions validation would go here if needed } break; - case 5: // Assigned Knowledge + case 'knowledge': if (!this.validateAssignedKnowledgeStep()) { return false; } break; - case 6: // Advanced + case 'advanced': // Advanced settings validation would go here if needed break; - case 7: // Summary + case 'summary': // Final validation would go here break; } @@ -3815,6 +4060,124 @@ export class AgentModalStepper { return Array.from(selectedCards).map(card => card.getAttribute('data-action-id')); } + findAvailableAction(actionId, actionName) { + return (this.availableActions || []).find(candidate => { + const candidateId = String(candidate?.id || candidate?.name || '').trim(); + const candidateName = String(candidate?.name || candidate?.display_name || '').trim(); + return (actionId && candidateId === actionId) || (actionName && candidateName === actionName); + }) || null; + } + + getCapabilityDefinitionsForActionType(actionType) { + switch (String(actionType || '').toLowerCase()) { + case 'simplechat': + return SIMPLECHAT_CAPABILITY_DEFINITIONS; + case 'msgraph': + return MSGRAPH_CAPABILITY_DEFINITIONS; + case 'chart': + return CHART_CAPABILITY_DEFINITIONS; + default: + return []; + } + } + + getEnabledCapabilitiesForAction(actionId, actionName, actionType) { + const definitions = this.getCapabilityDefinitionsForActionType(actionType); + if (!definitions.length) { + return []; + } + + let capabilities = {}; + switch (String(actionType || '').toLowerCase()) { + case 'simplechat': + capabilities = this.getSimpleChatCapabilitiesForAction(actionId, actionName); + break; + case 'msgraph': + capabilities = this.getMsGraphCapabilitiesForAction(actionId, actionName); + break; + case 'chart': + capabilities = this.getChartCapabilitiesForAction(actionId, actionName); + break; + default: + break; + } + + return definitions + .filter(definition => Boolean(capabilities[definition.key])) + .map(definition => ({ + key: definition.key, + label: definition.label, + description: definition.description + })); + } + + getSelectedActionsWithCapabilities() { + if (this.isAnyFoundryType()) { + return []; + } + + return Array.from(document.querySelectorAll('.action-card.border-primary')).map(card => { + const actionId = card.getAttribute('data-action-id') || ''; + const actionName = card.getAttribute('data-action-name') || ''; + const actionType = card.getAttribute('data-action-type') || 'custom'; + const catalogAction = this.findAvailableAction(actionId, actionName); + const displayName = String(catalogAction?.display_name || catalogAction?.name || actionName || '').trim(); + + return { + id: actionId, + name: actionName, + display_name: displayName || actionName, + description: card.getAttribute('data-action-description') || '', + type: actionType, + is_global: card.getAttribute('data-action-is-global') === 'true', + capabilities: this.getEnabledCapabilitiesForAction(actionId, actionName, actionType) + }; + }); + } + + getAssignedKnowledgeReference() { + const empty = { enabled: false, sources: [], documents: [], tags: [], web_sources: [] }; + if (this.isAnyFoundryType()) { + return empty; + } + + const config = this.normalizeAssignedKnowledge(this.getAssignedKnowledgeConfig()); + if (!config.enabled) { + return empty; + } + + const selectedSourceKeys = this.getAssignedKnowledgeSelectedSourceKeys(config); + const sources = (this.assignedKnowledgeCatalog.sources || []) + .filter(source => selectedSourceKeys.has(this.getCatalogSourceKey(source))) + .map(source => ({ + scope: String(source.scope || ''), + id: String(source.id || ''), + name: String(source.label || source.id || '') + })); + + const documents = this.getResolvedAssignedKnowledgeDocuments(config).map(({ documentItem, reasons }) => ({ + id: String(documentItem.id || ''), + title: String(documentItem.title || documentItem.file_name || ''), + file_name: String(documentItem.file_name || documentItem.title || ''), + source_name: String(documentItem.source_name || ''), + scope: String(documentItem.scope || ''), + tags: Array.isArray(documentItem.tags) ? documentItem.tags.map(tag => String(tag)) : [], + is_explicit: reasons.includes('explicit') + })); + + return { + enabled: true, + sources, + documents, + tags: this.normalizeStringArray(config.tags || []), + web_sources: (config.web_sources || []).map(webSource => ({ + url: String(webSource.url || ''), + mode: this.normalizeAssignedKnowledgeWebSourceMode(webSource.mode), + mode_label: this.getAssignedKnowledgeWebSourceModeLabel(webSource.mode) + })) + }; + } + setSelectedActions(actionIds) { if (!Array.isArray(actionIds)) return; @@ -4171,7 +4534,9 @@ export class AgentModalStepper { display_name: displayName, description, brief, - existing_instructions: existingInstructions + existing_instructions: existingInstructions, + selected_actions: this.getSelectedActionsWithCapabilities(), + assigned_knowledge: this.getAssignedKnowledgeReference() }) }); const result = await response.json().catch(() => ({})); diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html index 8bc34bdcd..0764a9f7b 100644 --- a/application/single_app/templates/_agent_modal.html +++ b/application/single_app/templates/_agent_modal.html @@ -54,15 +54,15 @@
3
-
Instructions
+
Actions
4
-
Actions
+
Knowledge
5
-
Knowledge
+
Instructions
6
@@ -326,34 +326,8 @@
Project Details
{% endif %}
- +
-
Instructions
- -
- - -
- - -
- - -
-
- -
- Tips: Be specific about the agent's role, capabilities, and limitations. Include examples of good behavior if relevant. -
-
-
- - -
@@ -491,8 +465,8 @@
Available Actions
- -
+ +
Assigned Knowledge
+ +
+
Instructions
+ +
+
+
+ +
+ 0 actions + 0 knowledge items +
+
+
+
+

+ Type # in the Instruction Brief or the instructions editor to reference these. + Use #action: for a selected action or one of its capabilities, and #knowledge: for an assigned document, workspace, tag, or web source. +

+
+
+
Selected Actions
+
+
+
+
Assigned Knowledge
+
+
+
+
+
+
+ + + +
+ + +
+ + +
+
The draft uses the actions and knowledge you selected in the previous steps.
+
+ +
+ Tips: Be specific about the agent's role, capabilities, and limitations. Include examples of good behavior if relevant. + Type # to reference a selected action, one of its capabilities, or an assigned document. +
+
+
+
Advanced Settings
@@ -784,7 +816,7 @@
Agent Summary

No actions selected

- Go back to step 4 to add actions to your agent + Go back to step 3 to add actions to your agent