From 9c998d6775521d314edb07f8ed113a1e38778df5 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 17 Aug 2026 18:03:56 -0400 Subject: [PATCH 1/2] Move agent Instructions after Actions and Knowledge, add # references The agent modal asked for Instructions at step 3, before Actions (4) and Assigned Knowledge (5), so authors wrote instructions before knowing what the agent could actually do, and Draft Instructions had no idea either. - Reorder steps to Basic Info, Model & Connection, Actions, Knowledge, Instructions, Advanced, Summary. Navigation and validation now branch on a named AGENT_STEP_KEYS map instead of hard-coded step numbers. - Add a collapsible "Selected Actions & Knowledge" panel to the Instructions step showing each selected action with its enabled capabilities, plus the assigned workspaces, documents, tags, and web sources with their tokens. - Add "#" autocomplete (agent_instruction_mentions.js) to the Instruction Brief and the SimpleMDE editor. It drills down namespace -> action -> capability, and #knowledge: lists documents, workspaces, tags, and web sources with type badges. Tokens such as #action:"Simple Chat":create_group are stored literally so they stay editable. - Send the selected actions and assigned knowledge to POST /api/agents/draft-instructions. The payload is untrusted prompt text only: it is normalized, per-item capped, and bounded by a shared total budget, and never affects authorization. The trigger scan is a linear reverse scan rather than a regex on purpose. A pattern describing optional quoted runs is ambiguous and backtracked exponentially on ordinary prose, so it ran per keystroke at seconds per call. Adds three functional tests and a Playwright UI test, and updates the UI tests that drove the modal by step number to look the step up by name. Fixes #1257 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/route_backend_agents.py | 212 +++- .../static/js/agent_instruction_mentions.js | 921 ++++++++++++++++++ .../static/js/agent_modal_stepper.js | 471 ++++++++- .../single_app/templates/_agent_modal.html | 229 ++++- .../AGENT_INSTRUCTION_CONTEXT_REFERENCES.md | 245 +++++ docs/explanation/release_notes.md | 25 + .../test_agent_draft_instructions_context.py | 471 +++++++++ .../test_agent_instruction_mention_tokens.py | 666 +++++++++++++ ...est_agent_modal_instructions_step_order.py | 295 ++++++ ...t_workspace_prompt_markdown_toolbar_fix.py | 5 +- ...est_agent_modal_assigned_knowledge_step.py | 4 +- ...test_agent_modal_instruction_references.py | 272 ++++++ .../test_agent_modal_msgraph_capabilities.py | 2 +- ...est_agent_modal_simplechat_capabilities.py | 2 +- 15 files changed, 3728 insertions(+), 94 deletions(-) create mode 100644 application/single_app/static/js/agent_instruction_mentions.js create mode 100644 docs/explanation/features/AGENT_INSTRUCTION_CONTEXT_REFERENCES.md create mode 100644 functional_tests/test_agent_draft_instructions_context.py create mode 100644 functional_tests/test_agent_instruction_mention_tokens.py create mode 100644 functional_tests/test_agent_modal_instructions_step_order.py create mode 100644 ui_tests/test_agent_modal_instruction_references.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4ce4fc421..675ef29d8 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.208" +VERSION = "0.250.209" 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 808d3477c..38f9af094 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']); @@ -215,7 +229,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'); @@ -228,6 +242,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: [] }; @@ -281,7 +296,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(); }); } @@ -1518,6 +1538,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(); @@ -1685,7 +1898,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'); @@ -1792,6 +2005,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() { @@ -1933,6 +2152,7 @@ export class AgentModalStepper { this.updateGeneratedName(); this.initializeInstructionsEditor(); this.initializeVoiceControls(); + this.initializeInstructionMentions(); this.syncAgentTypeSelector(); this.applyAgentTypeVisibility(); this.updateAgentTypeLock(); @@ -2504,7 +2724,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); //} @@ -2544,6 +2764,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++) { @@ -2559,50 +2797,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; } } @@ -2676,7 +2919,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'); @@ -2717,8 +2960,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'); @@ -2734,8 +2977,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'); @@ -2791,7 +3035,7 @@ export class AgentModalStepper { } break; - case 3: // Instructions + case 'instructions': { const instructionsValue = this.getInstructionsValue(); if (!this.isAnyFoundryType()) { if (!instructionsValue.trim()) { @@ -2806,24 +3050,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; } @@ -3809,6 +4054,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; @@ -4165,7 +4528,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