diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx index 6a3c01ae21e..15bfeb5c049 100644 --- a/apps/docs/components/workflow-preview/format-references.tsx +++ b/apps/docs/components/workflow-preview/format-references.tsx @@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] { const isReference = (part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}')) return isReference ? ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) : ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 91ebc37ea42..2d153a83769 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -22,7 +22,7 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' import { useForkMothershipChat } from '@/hooks/queries/mothership-chats' import { useFolderStore } from '@/stores/folders/store' -const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file' +const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question' function toPlainText(raw: string): string { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index afeac25cc3b..eac918b2bbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -279,6 +279,8 @@ const MARKDOWN_COMPONENTS = { interface ChatContentProps { content: string isStreaming?: boolean + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void @@ -287,6 +289,7 @@ interface ChatContentProps { function ChatContentInner({ content, isStreaming = false, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, onRevealStateChange, @@ -406,6 +409,7 @@ function ChatContentInner({ ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index f211a5f42e5..8151e32f11c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -2,4 +2,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group' export { ChatContent } from './chat-content' export { Options } from './options' +export { QuestionDisplay } from './question' export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts new file mode 100644 index 00000000000..5272577cfda --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts @@ -0,0 +1,5 @@ +export { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, + QuestionDisplay, +} from './question' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts new file mode 100644 index 00000000000..f3d7b9528ea --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const QUESTIONS: QuestionItem[] = [ + { + type: 'single_select', + prompt: 'How should I handle the duplicates?', + options: [{ id: 'keep_newest', label: 'Keep the newest entry' }], + }, + { + type: 'single_select', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], + }, + { + type: 'multi_select', + prompt: 'What time zone should the daily report run in?', + options: [ + { id: 'est', label: 'EST' }, + { id: 'pst', label: 'PST' }, + ], + }, +] + +describe('formatQuestionAnswerMessage', () => { + it('sends a prompt-answer line for a single question', () => { + expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe( + 'How should I handle the duplicates? — Keep the newest entry' + ) + }) + + it('sends one prompt-answer line per question for multi-step batches', () => { + expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe( + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'Delete 4 archived workflows? — Cancel\n' + + 'What time zone should the daily report run in? — EST' + ) + }) +}) + +describe('parseQuestionAnswerMessage', () => { + it('round-trips what formatQuestionAnswerMessage produces', () => { + const answers = ['Keep the newest entry', 'Cancel', 'EST, PST'] + const message = formatQuestionAnswerMessage(QUESTIONS, answers) + expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers) + }) + + it('round-trips a single question', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['Merge them']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual(['Merge them']) + }) + + it('rejects an unrelated user message (dismissed card, typed something else)', () => { + expect(parseQuestionAnswerMessage([QUESTIONS[0]], 'actually, show me the logs')).toBeNull() + }) + + it('rejects when the line count does not match the question count', () => { + const partial = formatQuestionAnswerMessage(QUESTIONS.slice(0, 2), ['A', 'B']) + expect(parseQuestionAnswerMessage(QUESTIONS, partial)).toBeNull() + }) + + it('rejects when a line pairs with the wrong prompt', () => { + const swapped = + 'Delete 4 archived workflows? — Cancel\n' + + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'What time zone should the daily report run in? — EST' + expect(parseQuestionAnswerMessage(QUESTIONS, swapped)).toBeNull() + }) + + it('preserves em-dashes inside the answer text', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['newest — but keep backups']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual([ + 'newest — but keep backups', + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx new file mode 100644 index 00000000000..f3e19b0ef3d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -0,0 +1,478 @@ +'use client' + +import { useState } from 'react' +import { + ArrowRight, + Button, + Check, + checkboxIconVariants, + checkboxVariants, + ChevronLeft, + ChevronRight, + cn, + X, +} from '@sim/emcn' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +/** + * Builds the single user message sent after the final question is answered: + * one `Prompt — Answer` line per question, for lone questions too. The uniform + * shape is what lets the chat pair this message back to its question card + * (see parseQuestionAnswerMessage) and render the card as the user turn + * instead of echoing a duplicate bubble. + */ +export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string { + return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n') +} + +/** + * Strictly matches a user message against a question batch's answer format: + * exactly one `Prompt — Answer` line per question, in order. Returns the + * answers, or null when the message is not this batch's answer — a dismissed + * card followed by an unrelated typed message must not match. + */ +export function parseQuestionAnswerMessage( + questions: QuestionItem[], + content: string +): string[] | null { + const lines = content.split('\n') + if (lines.length !== questions.length) return null + const answers: string[] = [] + for (const [i, question] of questions.entries()) { + const prefix = `${question.prompt} — ` + if (!lines[i].startsWith(prefix)) return null + answers.push(lines[i].slice(prefix.length)) + } + return answers +} + +const OPTION_ROW_CLASSES = + 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors' + +/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */ +const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0' + +/** Leading number slot matching the suggested follow-ups rows. */ +function RowNumber({ value }: { value: number }) { + return ( +
+ {value} +
+ ) +} + +/** + * Leading checkbox slot for multi_select rows. Purely presentational — it + * reuses the emcn Checkbox chrome via its exported variants, but the row + * button (or the free-text input) owns the interaction, so nesting a real + * Radix checkbox button inside the row button is avoided. + */ +function RowCheckbox({ checked, disabled }: { checked: boolean; disabled?: boolean }) { + return ( +
+ + {checked && ( + + )} + +
+ ) +} + +type QuestionPhase = 'active' | 'answered' | 'dismissed' + +interface QuestionDisplayProps { + data: QuestionItem[] + /** + * Answers resolved from the transcript (the paired user message that + * answered this card). When present the card renders as the answered recap + * — it IS the user turn; the paired message bubble is hidden by the chat. + */ + answers?: string[] + /** Sends the combined answer as a user message; undefined renders the div inert. */ + onSelect?: (message: string) => void +} + +/** + * Inline renderer for the `` special tag: a chat-inline div with the + * user input's chrome, the current question's prompt at the top left, dismiss + * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and + * suggested-action option rows beneath, always followed by a "Something else" + * row that reads as a plain option until clicked and then becomes the focused + * text box. `single_select` answers and advances on click (or on submitting + * typed text); `multi_select` rows toggle checkboxes and an option-styled + * Submit row confirms the step. Answering the last question sends one + * combined user message and collapses the div to a question/answer recap. + */ +export function QuestionDisplay({ + data, + answers: transcriptAnswers, + onSelect, +}: QuestionDisplayProps) { + const disabled = !onSelect + const [phase, setPhase] = useState('active') + const [step, setStep] = useState(0) + const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => [])) + const [customByStep, setCustomByStep] = useState(() => data.map(() => '')) + const [freeText, setFreeText] = useState('') + // The "Something else" row reads as a plain option until clicked, then + // becomes the focused text box (and reverts when left empty). + const [freeTextEditing, setFreeTextEditing] = useState(false) + // multi_select only: whether the typed "Something else" text is included in + // the answer. Unchecking keeps the text; it just stops counting. + const [customCheckedByStep, setCustomCheckedByStep] = useState(() => + data.map(() => false) + ) + + // The typed text that actually joins a step's answer: multi_select customs + // only count while checked; single_select customs always count. + const customFor = (i: number, customs: string[]): string => + data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '') + + const containerClasses = + 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' + + // Transcript answers win over local state: they survive reloads (local + // phase does not) and keep live + rehydrated renders identical. + const localAnswers = + phase === 'answered' + ? data.map((question, i) => + answerFor(question, selectedByStep[i] ?? [], customFor(i, customByStep)) + ) + : null + const recapAnswers = transcriptAnswers ?? localAnswers + if (data.length > 0 && recapAnswers) { + return ( +
+ {data.map((question, i) => ( +
+

{question.prompt}

+

{recapAnswers[i]}

+
+ ))} +
+ ) + } + + if (data.length === 0 || phase === 'dismissed') return null + + const question = data[step] + const isLast = step === data.length - 1 + const options = question.options + const selected = selectedByStep[step] ?? [] + const isMulti = question.type === 'multi_select' + + const commitCustom = (): string[] => { + const next = [...customByStep] + next[step] = freeText.trim() + setCustomByStep(next) + return next + } + + const goToStep = (next: number) => { + commitCustom() + setStep(next) + const prefill = customByStep[next] ?? '' + setFreeText(prefill) + setFreeTextEditing(prefill.trim().length > 0) + } + + const finishStep = (selections: string[][], customs: string[]) => { + if (!isLast) { + setStep(step + 1) + const prefill = customs[step + 1] ?? '' + setFreeText(prefill) + setFreeTextEditing(prefill.trim().length > 0) + return + } + setPhase('answered') + onSelect?.( + formatQuestionAnswerMessage( + data, + data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs))) + ) + ) + } + + const handleSingleSelect = (label: string) => { + const selections = [...selectedByStep] + selections[step] = [label] + setSelectedByStep(selections) + const customs = [...customByStep] + customs[step] = '' + setCustomByStep(customs) + setFreeText('') + finishStep(selections, customs) + } + + const handleMultiToggle = (label: string) => { + const selections = [...selectedByStep] + const current = selections[step] ?? [] + selections[step] = current.includes(label) + ? current.filter((l) => l !== label) + : [...current, label] + setSelectedByStep(selections) + } + + /** multi_select confirm: commits selections and/or typed text, then advances. */ + const submitMultiStep = () => { + finishStep(selectedByStep, commitCustom()) + } + + /** Sets whether the typed "Something else" text counts — never touches the text. */ + const setCustomChecked = (checked: boolean) => { + const next = [...customCheckedByStep] + next[step] = checked + setCustomCheckedByStep(next) + } + + /** single_select free-text arrow: the typed text IS the answer. */ + const submitSingleFreeText = () => { + const customs = commitCustom() + const selections = [...selectedByStep] + selections[step] = [] + setSelectedByStep(selections) + finishStep(selections, customs) + } + + const stepAnswered = (i: number): boolean => { + if ((selectedByStep[i]?.length ?? 0) > 0) return true + const text = i === step ? freeText : (customByStep[i] ?? '') + if (text.trim().length === 0) return false + return data[i].type === 'multi_select' ? (customCheckedByStep[i] ?? false) : true + } + + const canSubmitStep = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) + + return ( +
+
+

+ {question.prompt} +

+
+ {data.length > 1 && ( +
+ + + {step + 1} of {data.length} + + +
+ )} + {!disabled && ( + + )} +
+
+
+ {options.map((option, i) => { + const isSelected = selected.includes(option.label) + return ( + + ) + })} + {freeTextEditing ? ( +
0 && 'border-t')}> + {isMulti ? ( + // Checked from the moment the row is clicked into; blur with + // nothing typed reverts to the plain option row. A real button + // (the editing row is a div, so no nesting hazard) so the box + // can be toggled even after typing — unchecking keeps the text, + // it just stops counting toward the answer. +
+ +
+ ) : ( + + )} + setFreeText(e.target.value)} + onBlur={() => { + if (freeText.trim().length === 0) { + setFreeTextEditing(false) + if (isMulti) setCustomChecked(false) + } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.currentTarget.blur() + return + } + if (e.key === 'Enter' && canSubmitStep) { + e.preventDefault() + if (isMulti) { + submitMultiStep() + } else { + submitSingleFreeText() + } + } + }} + aria-label={question.prompt} + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none disabled:cursor-not-allowed' + /> + {!isMulti && ( + + )} +
+ ) : ( + + )} + {isMulti && ( + + )} +
+
+ ) +} + +/** + * A step's combined answer: selected option labels in option order, with the + * typed "Something else" entry appended last. single_select carries at most + * one selection, so this collapses to the chosen label or the typed text. + */ +function answerFor(question: QuestionItem, selected: string[], custom: string): string { + const ordered = question.options + .map((option) => option.label) + .filter((label) => selected.includes(label)) + const parts = custom.trim() ? [...ordered, custom.trim()] : ordered + return parts.join(', ') +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index c45b9199c31..16dbf2783e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -6,6 +6,10 @@ export type { MothershipErrorTagData, OptionsTagData, ParsedSpecialContent, + QuestionItem, + QuestionOption, + QuestionTagData, + QuestionType, RuntimeSpecialTagName, UsageUpgradeAction, UsageUpgradeTagData, @@ -17,9 +21,12 @@ export { PendingTagIndicator, parseFileTag, parseJsonTagBody, + parseLastQuestionTag, + parseQuestionTagBody, parseSpecialTags, parseTagAttributes, parseTextTagBody, + QUESTION_TYPES, SpecialTags, USAGE_UPGRADE_ACTIONS, WORKSPACE_RESOURCE_TAG_TYPES, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts new file mode 100644 index 00000000000..638e3c3a747 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + parseQuestionTagBody, + parseSpecialTags, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const SINGLE_SELECT = { + type: 'single_select', + prompt: 'How should I handle the duplicate emails?', + options: [ + { id: 'keep_newest', label: 'Keep the newest entry' }, + { id: 'merge', label: 'Merge fields into one row' }, + ], +} + +const YES_NO = { + type: 'single_select', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], +} + +const MULTI_SELECT = { + type: 'multi_select', + prompt: 'Which channels should the report go to?', + options: [ + { id: 'slack', label: 'Slack' }, + { id: 'email', label: 'Email' }, + { id: 'sheet', label: 'Google Sheet' }, + ], +} + +describe('parseQuestionTagBody', () => { + it('normalizes a single object body to a one-element array', () => { + expect(parseQuestionTagBody(JSON.stringify(SINGLE_SELECT))).toEqual([SINGLE_SELECT]) + }) + + it('preserves array order for multi-step bodies', () => { + const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])) + expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT]) + }) + + it('accepts multi_select questions', () => { + expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT]) + }) + + it('rejects single_select without options', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Pick' }))).toBe( + null + ) + }) + + it('rejects empty options', () => { + expect( + parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Sure?', options: [] })) + ).toBe(null) + }) + + it('rejects the removed text and confirm types', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe( + null + ) + expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null) + }) + + it('strips agent-supplied catch-all options (the card provides its own)', () => { + const withOther = { + ...SINGLE_SELECT, + options: [...SINGLE_SELECT.options, { id: 'other', label: 'Something else' }], + } + expect(parseQuestionTagBody(JSON.stringify(withOther))).toEqual([SINGLE_SELECT]) + }) + + it('rejects a question whose every option is a catch-all', () => { + const onlyOther = { + type: 'single_select', + prompt: 'Pick one', + options: [ + { id: 'a', label: 'Other' }, + { id: 'b', label: 'None of the above' }, + ], + } + expect(parseQuestionTagBody(JSON.stringify(onlyOther))).toBe(null) + }) + + it('rejects an empty prompt', () => { + expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, prompt: ' ' }))).toBe(null) + }) + + it('rejects a malformed option', () => { + expect( + parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, options: [{ id: 'keep_newest' }] })) + ).toBe(null) + }) + + it('rejects an array containing one invalid question', () => { + expect(parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, { type: 'single_select' }]))).toBe( + null + ) + }) + + it('rejects empty arrays and non-JSON bodies', () => { + expect(parseQuestionTagBody('[]')).toBe(null) + expect(parseQuestionTagBody('not json')).toBe(null) + }) +}) + +describe('parseSpecialTags with ', () => { + it('extracts a complete question tag interleaved with text', () => { + const content = `Before the tag. ${JSON.stringify(SINGLE_SELECT)} After the tag.` + const { segments, hasPendingTag } = parseSpecialTags(content, false) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before the tag. ' }, + { type: 'question', data: [SINGLE_SELECT] }, + { type: 'text', content: ' After the tag.' }, + ]) + }) + + it('extracts a multi-step array body as one segment', () => { + const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}` + const { segments } = parseSpecialTags(content, false) + expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }]) + }) + + it('flags an unclosed question tag as pending while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags( + 'Thinking about it. [{"type":"single_sel', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }]) + }) + + it('strips a trailing partial opening tag while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { + const { segments, hasPendingTag } = parseSpecialTags( + 'Before. {"type":"single_select"} After.', + false + ) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before. ' }, + { type: 'text', content: ' After.' }, + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index e8de183ad03..73109b106da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -17,6 +17,7 @@ import { useParams } from 'next/navigation' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' import type { ChatMessageContext, MothershipResource, @@ -94,6 +95,30 @@ export interface FileTagData { content: string } +export const QUESTION_TYPES = ['single_select', 'multi_select'] as const + +export type QuestionType = (typeof QUESTION_TYPES)[number] + +export interface QuestionOption { + id: string + label: string +} + +/** + * One question in a `` tag: a single_select or multi_select with at + * least one real option. The card always appends its own free-text "Something + * else" row, so agent-supplied catch-all options ("Other", "Something else", + * ...) are stripped during parsing. + */ +export interface QuestionItem { + type: QuestionType + prompt: string + options: QuestionOption[] +} + +/** Normalized `` payload: single-object bodies become a one-element array. */ +export type QuestionTagData = QuestionItem[] + export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number] @@ -113,6 +138,7 @@ export type ContentSegment = | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } export type RuntimeSpecialTagName = | 'thinking' @@ -121,6 +147,7 @@ export type RuntimeSpecialTagName = | 'mothership-error' | 'file' | 'workspace_resource' + | 'question' export interface ParsedSpecialContent { segments: ContentSegment[] @@ -134,6 +161,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [ 'mothership-error', 'file', 'workspace_resource', + 'question', ] as const const SPECIAL_TAG_NAMES = [ @@ -143,6 +171,7 @@ const SPECIAL_TAG_NAMES = [ 'credential', 'mothership-error', 'workspace_resource', + 'question', ] as const function isRecord(value: unknown): value is Record { @@ -220,6 +249,83 @@ function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceT return id.length > 0 } +function isQuestionOption(value: unknown): value is QuestionOption { + if (!isRecord(value)) return false + return typeof value.id === 'string' && typeof value.label === 'string' +} + +/** + * Catch-all labels the agent must not supply as options — the card renders + * its own free-text "Something else" row. Matching options are stripped; a + * question left with no real options is invalid. + */ +const SELF_PROVIDED_OPTION_LABELS = new Set([ + 'other', + 'others', + 'something else', + 'none of the above', + 'none of these', +]) + +function isQuestionItem(value: unknown): value is QuestionItem { + if (!isRecord(value)) return false + if ( + typeof value.type !== 'string' || + !(QUESTION_TYPES as readonly string[]).includes(value.type) + ) { + return false + } + if (typeof value.prompt !== 'string' || value.prompt.trim().length === 0) return false + return ( + Array.isArray(value.options) && + value.options.length > 0 && + value.options.every(isQuestionOption) + ) +} + +/** Strips agent-supplied catch-all options; null when none remain. */ +function sanitizeQuestionItem(item: QuestionItem): QuestionItem | null { + const options = item.options.filter( + (option) => !SELF_PROVIDED_OPTION_LABELS.has(option.label.trim().toLowerCase()) + ) + if (options.length === 0) return null + return options.length === item.options.length ? item : { ...item, options } +} + +/** + * Parses a `` tag body. Accepts a single question object or a + * non-empty array of them; single objects are normalized to a one-element + * array so the renderer only handles the array shape. + */ +/** + * Extracts the last complete `` tag payload from raw message + * content. Used by the chat list to pair an assistant question card with the + * user message that answered it. + */ +export function parseLastQuestionTag(content: string): QuestionTagData | null { + const matches = content.match(/([\s\S]*?)<\/question>/g) + if (!matches || matches.length === 0) return null + const last = matches[matches.length - 1] + return parseQuestionTagBody(last.slice(''.length, -''.length)) +} + +export function parseQuestionTagBody(body: string): QuestionTagData | null { + try { + const parsed = JSON.parse(body) as unknown + const items = Array.isArray(parsed) ? parsed : [parsed] + if (items.length === 0 || !items.every(isQuestionItem)) return null + const sanitized: QuestionItem[] = [] + for (const item of items) { + const clean = sanitizeQuestionItem(item) + if (!clean) return null + sanitized.push(clean) + } + return sanitized + } catch { + return null + } +} + export function parseJsonTagBody( body: string, isExpectedShape: (value: unknown) => value is T @@ -268,6 +374,7 @@ function parseSpecialTagData( | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } | null { if (tagName === 'thinking') { const content = parseTextTagBody(body) @@ -299,6 +406,11 @@ function parseSpecialTagData( return data ? { type: 'workspace_resource', data } : null } + if (tagName === 'question') { + const data = parseQuestionTagBody(body) + return data ? { type: 'question', data } : null + } + return null } @@ -398,6 +510,8 @@ const THINKING_BLOCKS = [ interface SpecialTagsProps { segment: Exclude + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void } @@ -408,6 +522,7 @@ interface SpecialTagsProps { */ export function SpecialTags({ segment, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, }: SpecialTagsProps) { @@ -424,6 +539,10 @@ export function SpecialTags({ return case 'workspace_resource': return + case 'question': + return ( + + ) default: return null } @@ -760,7 +879,7 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { href={data.value} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]' + className='flex items-center gap-2 rounded-2xl border border-[var(--border-1)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]' > {createElement(Icon, { className: 'size-[16px] shrink-0' })} Connect {data.provider} @@ -788,7 +907,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' return ( -
+
void onPhaseChange?: (phase: MessagePhase) => void } @@ -659,6 +661,7 @@ function MessageContentInner({ blocks, fallbackContent, isStreaming = false, + questionAnswers, onOptionSelect, onPhaseChange, }: MessageContentProps) { @@ -724,6 +727,7 @@ function MessageContentInner({ segmentIndex: i, segmentCount: segments.length, })} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onWorkspaceResourceSelect={onWorkspaceResourceSelect} onRevealStateChange={ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 6a06125c07a..350eae9d6f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -38,6 +38,7 @@ const TOOL_ICONS: Record = { manage_skill: Asterisk, user_memory: Database, function_execute: TerminalWindow, + run_code: TerminalWindow, superagent: Blimp, user_table: TableIcon, workspace_file: File, @@ -51,12 +52,16 @@ const TOOL_ICONS: Record = { auth: Integration, knowledge: Database, knowledge_base: Database, + search_knowledge_base: Database, table: TableIcon, + query_user_table: TableIcon, scheduled_task: Calendar, job: Calendar, agent: AgentIcon, custom_tool: Wrench, research: Search, + scout: Search, + search: Search, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 841538b2e17..c85ef9aa7db 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -20,7 +20,11 @@ import { MessageContent, type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' -import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' +import { + PendingTagIndicator, + parseLastQuestionTag, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -163,6 +167,8 @@ interface AssistantMessageRowProps { message: ChatMessage isStreaming: boolean precedingUserContent?: string + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] rowClassName: string onOptionSelect?: (id: string) => void onAnimatingChange?: (animating: boolean) => void @@ -172,6 +178,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ message, isStreaming, precedingUserContent, + questionAnswers, rowClassName, onOptionSelect, onAnimatingChange, @@ -197,7 +204,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - const showActions = phase === 'settled' && (message.content || hasAnyBlocks) + // A message that ends with a question card is an input surface, not a + // reactable assistant turn: no copy/thumbs row beneath the card, whether + // the card is awaiting answers or collapsed to its recap. + const endsWithQuestion = trimmedContent.endsWith('') + const showActions = phase === 'settled' && !endsWithQuestion && (message.content || hasAnyBlocks) return (
@@ -205,6 +216,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ blocks={blocks} fallbackContent={message.content} isStreaming={isStreaming} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onPhaseChange={setPhase} /> @@ -303,6 +315,29 @@ export function MothershipChat({ return out }, [messages]) + /** + * Pairs each assistant question card with the user message that answered it + * (strict `Prompt — Answer` match). The paired user message is hidden — the + * answered card IS the user turn — and the assistant row renders the card + * as a recap with these answers, both live and after reload. + */ + const questionPairing = useMemo(() => { + const answersByIndex: Array = [] + const hiddenUserByIndex: Array = [] + for (const [index, message] of messages.entries()) { + if (message.role !== 'assistant' || !message.content?.includes('')) continue + const next = messages[index + 1] + if (!next || next.role !== 'user' || !next.content) continue + const questions = parseLastQuestionTag(message.content) + if (!questions) continue + const answers = parseQuestionAnswerMessage(questions, next.content) + if (!answers) continue + answersByIndex[index] = answers + hiddenUserByIndex[index + 1] = true + } + return { answersByIndex, hiddenUserByIndex } + }, [messages]) + /** * Always keep the last row in the rendered window. It is the live/streaming * row; unmounting it (by scrolling far enough up that it leaves the overscan @@ -424,19 +459,22 @@ export function MothershipChat({ style={{ transform: `translateY(${virtualItem.start}px)` }} > {msg.role === 'user' ? ( - + questionPairing.hiddenUserByIndex[index] ? null : ( + + ) ) : ( = { knowledge: 'Knowledge Agent', table: 'Table Agent', custom_tool: 'Custom Tool Agent', + scout: 'Scout Agent', + search: 'Search Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts index fefc4534ea3..f99d761574f 100644 --- a/apps/sim/lib/copilot/generated/metrics-v1.ts +++ b/apps/sim/lib/copilot/generated/metrics-v1.ts @@ -28,6 +28,8 @@ export const Metric = { CopilotVfsMaterializeDuration: 'copilot.vfs.materialize.duration', GenAiClientCacheTokenUsage: 'gen_ai.client.cache.token.usage', GenAiClientTokenUsage: 'gen_ai.client.token.usage', + LlmClientCompactions: 'llm.client.compactions', + LlmClientContextTokens: 'llm.client.context_tokens', LlmClientErrors: 'llm.client.errors', LlmClientOutputCutoff: 'llm.client.output_cutoff', LlmClientStreamDuration: 'llm.client.stream.duration', @@ -53,6 +55,8 @@ export const MetricValues: readonly MetricValue[] = [ 'copilot.vfs.materialize.duration', 'gen_ai.client.cache.token.usage', 'gen_ai.client.token.usage', + 'llm.client.compactions', + 'llm.client.context_tokens', 'llm.client.errors', 'llm.client.output_cutoff', 'llm.client.stream.duration', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4a4c24a1594..ac259253df3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -71,22 +71,25 @@ export interface ToolCatalogEntry { | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' | 'rename_file' | 'rename_file_folder' | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' @@ -98,7 +101,6 @@ export interface ToolCatalogEntry { | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -169,22 +171,25 @@ export interface ToolCatalogEntry { | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' | 'rename_file' | 'rename_file_folder' | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' @@ -196,7 +201,6 @@ export interface ToolCatalogEntry { | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -211,9 +215,9 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' - | 'research' | 'run' | 'scheduled_task' + | 'search' | 'superagent' | 'table' | 'workflow' @@ -3015,6 +3019,55 @@ export const QueryLogs: ToolCatalogEntry = { }, } +export const QueryUserTable: ToolCatalogEntry = { + id: 'query_user_table', + name: 'query_user_table', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { type: 'string', description: 'Table ID (required for all operations)' }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Read: ToolCatalogEntry = { id: 'read', name: 'read', @@ -3182,20 +3235,6 @@ export const RenameWorkflow: ToolCatalogEntry = { requiredPermission: 'write', } -export const Research: ToolCatalogEntry = { - id: 'research', - name: 'research', - route: 'subagent', - mode: 'async', - parameters: { - properties: { topic: { description: 'The topic to research.', type: 'string' } }, - required: ['topic'], - type: 'object', - }, - subagentId: 'research', - internal: true, -} - export const Respond: ToolCatalogEntry = { id: 'respond', name: 'respond', @@ -3293,6 +3332,99 @@ export const RunBlock: ToolCatalogEntry = { clientExecutable: true, } +export const RunCode: ToolCatalogEntry = { + id: 'run_code', + name: 'run_code', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + capabilities: ['file_input', 'directory_input', 'table_input'], +} + export const RunFromBlock: ToolCatalogEntry = { id: 'run_from_block', name: 'run_from_block', @@ -3456,6 +3588,26 @@ export const ScrapePage: ToolCatalogEntry = { }, } +export const Search: ToolCatalogEntry = { + id: 'search', + name: 'search', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'search', + internal: true, +} + export const SearchDocumentation: ToolCatalogEntry = { id: 'search_documentation', name: 'search_documentation', @@ -3471,6 +3623,49 @@ export const SearchDocumentation: ToolCatalogEntry = { }, } +export const SearchKnowledgeBase: ToolCatalogEntry = { + id: 'search_knowledge_base', + name: 'search_knowledge_base', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { type: 'string', description: "Search query text (required for 'query')" }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const SearchLibraryDocs: ToolCatalogEntry = { id: 'search_library_docs', name: 'search_library_docs', @@ -3755,50 +3950,6 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const UserMemory: ToolCatalogEntry = { - id: 'user_memory', - name: 'user_memory', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { type: 'number', description: 'Number of results for search (default 10)' }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { type: 'string', description: 'Search query to find relevant memories' }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { type: 'string', description: 'Value to remember' }, - }, - required: ['operation'], - }, -} - export const UserTable: ToolCatalogEntry = { id: 'user_table', name: 'user_table', @@ -4461,22 +4612,36 @@ export const MaterializeFileOperationValues = [ MaterializeFileOperation.import, ] as const -export const UserMemoryOperation = { - add: 'add', - search: 'search', - delete: 'delete', - correct: 'correct', - list: 'list', +export const QueryUserTableOperation = { + get: 'get', + getSchema: 'get_schema', + getRow: 'get_row', + queryRows: 'query_rows', +} as const + +export type QueryUserTableOperation = + (typeof QueryUserTableOperation)[keyof typeof QueryUserTableOperation] + +export const QueryUserTableOperationValues = [ + QueryUserTableOperation.get, + QueryUserTableOperation.getSchema, + QueryUserTableOperation.getRow, + QueryUserTableOperation.queryRows, +] as const + +export const SearchKnowledgeBaseOperation = { + get: 'get', + query: 'query', + listTags: 'list_tags', } as const -export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation] +export type SearchKnowledgeBaseOperation = + (typeof SearchKnowledgeBaseOperation)[keyof typeof SearchKnowledgeBaseOperation] -export const UserMemoryOperationValues = [ - UserMemoryOperation.add, - UserMemoryOperation.search, - UserMemoryOperation.delete, - UserMemoryOperation.correct, - UserMemoryOperation.list, +export const SearchKnowledgeBaseOperationValues = [ + SearchKnowledgeBaseOperation.get, + SearchKnowledgeBaseOperation.query, + SearchKnowledgeBaseOperation.listTags, ] as const export const UserTableOperation = { @@ -4629,22 +4794,25 @@ export const TOOL_CATALOG: Record = { [OpenResource.id]: OpenResource, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, + [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, [Redeploy.id]: Redeploy, [RenameFile.id]: RenameFile, [RenameFileFolder.id]: RenameFileFolder, [RenameWorkflow.id]: RenameWorkflow, - [Research.id]: Research, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, [Run.id]: Run, [RunBlock.id]: RunBlock, + [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, + [Search.id]: Search, [SearchDocumentation.id]: SearchDocumentation, + [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, @@ -4656,7 +4824,6 @@ export const TOOL_CATALOG: Record = { [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, - [UserMemory.id]: UserMemory, [UserTable.id]: UserTable, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index dcaea0db6ea..1a4948e59fc 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { + ['complete_scheduled_task']: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { + ['create_file_folder']: { parameters: { type: 'object', properties: { @@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workflow: { + ['create_workflow']: { parameters: { type: 'object', properties: { @@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_file_folder: { + ['delete_file_folder']: { parameters: { type: 'object', properties: { @@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -313,7 +313,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { @@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -570,7 +570,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_mcp: { + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -686,7 +686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - diff_workflows: { + ['diff_workflows']: { parameters: { type: 'object', properties: { @@ -710,7 +710,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -759,7 +759,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -791,7 +791,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -830,7 +830,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + ['enrichment_run']: { parameters: { type: 'object', properties: { @@ -874,7 +874,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ffmpeg: { + ['ffmpeg']: { parameters: { type: 'object', properties: { @@ -1055,7 +1055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { properties: { prompt: { @@ -1068,7 +1068,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -1206,7 +1206,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -1224,7 +1224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_audio: { + ['generate_audio']: { parameters: { type: 'object', properties: { @@ -1376,7 +1376,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -1504,7 +1504,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_video: { + ['generate_video']: { parameters: { type: 'object', properties: { @@ -1671,7 +1671,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -1692,7 +1692,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1714,7 +1714,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1727,7 +1727,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + ['get_deployment_log']: { parameters: { type: 'object', properties: { @@ -1740,7 +1740,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1768,14 +1768,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_scheduled_task_logs: { + ['get_scheduled_task_logs']: { parameters: { type: 'object', properties: { @@ -1800,7 +1800,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1819,7 +1819,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_run_options: { + ['get_workflow_run_options']: { parameters: { type: 'object', properties: { @@ -1832,7 +1832,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1851,7 +1851,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -1899,7 +1899,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -1912,7 +1912,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -2105,7 +2105,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { + ['list_file_folders']: { parameters: { type: 'object', properties: { @@ -2117,7 +2117,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_integration_tools: { + ['list_integration_tools']: { parameters: { properties: { integration: { @@ -2131,14 +2131,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -2151,7 +2151,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_deployment: { + ['load_deployment']: { parameters: { type: 'object', properties: { @@ -2170,7 +2170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_integration_tool: { + ['load_integration_tool']: { parameters: { properties: { tool_ids: { @@ -2187,7 +2187,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -2216,7 +2216,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -2296,7 +2296,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { + ['manage_folder']: { parameters: { type: 'object', properties: { @@ -2335,7 +2335,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_mcp_tool: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -2387,7 +2387,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { + ['manage_scheduled_task']: { parameters: { type: 'object', properties: { @@ -2462,7 +2462,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -2495,7 +2495,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -2519,7 +2519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - media: { + ['media']: { parameters: { properties: { prompt: { @@ -2532,7 +2532,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + ['move_file']: { parameters: { type: 'object', properties: { @@ -2553,7 +2553,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file_folder: { + ['move_file_folder']: { parameters: { type: 'object', properties: { @@ -2571,7 +2571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_workflow: { + ['move_workflow']: { parameters: { type: 'object', properties: { @@ -2591,7 +2591,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { @@ -2605,7 +2605,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -2619,7 +2619,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -2653,7 +2653,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - promote_to_live: { + ['promote_to_live']: { parameters: { type: 'object', properties: { @@ -2672,7 +2672,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_logs: { + ['query_logs']: { parameters: { type: 'object', properties: { @@ -2783,7 +2783,69 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - read: { + ['query_user_table']: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: 'MongoDB-style filter for query_rows', + }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { + type: 'string', + description: 'Row ID (required for get_row)', + }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { + type: 'string', + description: 'Table ID (required for all operations)', + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + ['read']: { parameters: { type: 'object', properties: { @@ -2810,7 +2872,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -2889,7 +2951,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { + ['rename_file']: { parameters: { type: 'object', properties: { @@ -2925,7 +2987,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - rename_file_folder: { + ['rename_file_folder']: { parameters: { type: 'object', properties: { @@ -2942,7 +3004,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - rename_workflow: { + ['rename_workflow']: { parameters: { type: 'object', properties: { @@ -2959,20 +3021,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - research: { - parameters: { - properties: { - topic: { - description: 'The topic to research.', - type: 'string', - }, - }, - required: ['topic'], - type: 'object', - }, - resultSchema: undefined, - }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -2995,7 +3044,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -3013,7 +3062,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -3030,7 +3079,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -3062,7 +3111,100 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_code']: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + resultSchema: undefined, + }, + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -3094,7 +3236,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -3132,7 +3274,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -3175,7 +3317,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { + ['scheduled_task']: { parameters: { properties: { request: { @@ -3188,7 +3330,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -3209,7 +3351,21 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search']: { + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -3226,7 +3382,57 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_library_docs: { + ['search_knowledge_base']: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { + type: 'string', + description: "Search query text (required for 'query')", + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -3247,7 +3453,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -3287,7 +3493,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -3309,7 +3515,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -3331,7 +3537,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -3365,7 +3571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -3406,7 +3612,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { + ['superagent']: { parameters: { properties: { task: { @@ -3420,7 +3626,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - table: { + ['table']: { parameters: { properties: { request: { @@ -3433,7 +3639,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_deployment_version: { + ['update_deployment_version']: { parameters: { type: 'object', properties: { @@ -3462,7 +3668,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { + ['update_scheduled_task_history']: { parameters: { type: 'object', properties: { @@ -3480,7 +3686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -3505,56 +3711,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { - type: 'number', - description: 'Number of results for search (default 10)', - }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { - type: 'string', - description: 'Search query to find relevant memories', - }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { - type: 'string', - description: 'Value to remember', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -3917,7 +4074,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { properties: { prompt: { @@ -3930,7 +4087,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index c5024ee3571..a013bc95844 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -89,6 +89,10 @@ export const TraceAttr = { ChatArtifactKeys: 'chat.artifact_keys', ChatArtifactsBytes: 'chat.artifacts_bytes', ChatAuthType: 'chat.auth_type', + ChatBlobBytesOffloaded: 'chat.blob_bytes_offloaded', + ChatBlobBytesResolved: 'chat.blob_bytes_resolved', + ChatBlobsOffloaded: 'chat.blobs_offloaded', + ChatBlobsResolved: 'chat.blobs_resolved', ChatContextCount: 'chat.context_count', ChatContextUsage: 'chat.context_usage', ChatContinuationMessagesBefore: 'chat.continuation.messages_before', @@ -420,6 +424,7 @@ export const TraceAttr = { GenAiUsageCacheCreationTokens: 'gen_ai.usage.cache_creation_tokens', GenAiUsageCacheReadInputTokens: 'gen_ai.usage.cache_read.input_tokens', GenAiUsageCacheReadTokens: 'gen_ai.usage.cache_read_tokens', + GenAiUsageContextTokens: 'gen_ai.usage.context_tokens', GenAiUsageInputTokens: 'gen_ai.usage.input_tokens', GenAiUsageOutputTokens: 'gen_ai.usage.output_tokens', GenAiUsageThinkingTokens: 'gen_ai.usage.thinking_tokens', @@ -446,9 +451,11 @@ export const TraceAttr = { KnowledgeBaseId: 'knowledge_base.id', KnowledgeBaseName: 'knowledge_base.name', LlmBackend: 'llm.backend', + LlmCompactionPause: 'llm.compaction.pause', LlmErrorStage: 'llm.error_stage', LlmProtocol: 'llm.protocol', LlmRequestBodyBytes: 'llm.request.body_bytes', + LlmRequestCompactionTrigger: 'llm.request.compaction_trigger', LlmStreamBytes: 'llm.stream.bytes', LlmStreamChunks: 'llm.stream.chunks', LlmStreamFirstChunkBytes: 'llm.stream.first_chunk_bytes', @@ -716,6 +723,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'chat.artifact_keys', 'chat.artifacts_bytes', 'chat.auth_type', + 'chat.blob_bytes_offloaded', + 'chat.blob_bytes_resolved', + 'chat.blobs_offloaded', + 'chat.blobs_resolved', 'chat.context_count', 'chat.context_usage', 'chat.continuation.messages_before', @@ -1036,6 +1047,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.usage.cache_creation_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.cache_read_tokens', + 'gen_ai.usage.context_tokens', 'gen_ai.usage.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.thinking_tokens', @@ -1062,9 +1074,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'knowledge_base.id', 'knowledge_base.name', 'llm.backend', + 'llm.compaction.pause', 'llm.error_stage', 'llm.protocol', 'llm.request.body_bytes', + 'llm.request.compaction_trigger', 'llm.stream.bytes', 'llm.stream.chunks', 'llm.stream.first_chunk_bytes', diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/copilot/request/tool-call-state.ts index 70433429043..d4bf91e02e0 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.ts +++ b/apps/sim/lib/copilot/request/tool-call-state.ts @@ -60,14 +60,30 @@ export function getToolCallTerminalData( toolCall: Pick ): unknown { const output = getToolCallStateOutput(toolCall) + const failed = !isSuccessfulToolCallStatus(toolCall.status as TerminalToolCallStatus) + if (output !== undefined) { - return output + if (!failed) { + return output + } + /** + * A failed call must always surface its error in the terminal data — this + * is what the model reads on resume. Handlers can fail with an + * empty-but-defined output (the app-tool executor's "Tool not found" ships + * `output: {}`), and preferring that output rendered failures as bare `{}`, + * so the model retried blind instead of reacting to the error. + */ + const error = + typeof toolCall.error === 'string' && toolCall.error.length > 0 + ? toolCall.error + : 'Tool failed without an error message' + if (output && typeof output === 'object' && !Array.isArray(output)) { + return 'error' in output ? output : { ...output, error } + } + return { output, error } } - if ( - toolCall.status === MothershipStreamV1ToolOutcome.success || - toolCall.status === MothershipStreamV1ToolOutcome.skipped - ) { + if (!failed) { return undefined } diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index f479d5e99b7..0b6e28b28a7 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -33,12 +33,13 @@ import { KnowledgeBase, MaterializeFile, Media, - Research, Run, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, + Search, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -225,12 +226,13 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ RunWorkflow.id, RunWorkflowUntilBlock.id, FunctionExecute.id, + RunCode.id, GenerateImage.id, GenerateAudio.id, GenerateVideo.id, Ffmpeg.id, Media.id, - Research.id, + Search.id, CrawlWebsite.id, KnowledgeBase.id, DownloadToWorkspaceFile.id, diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b39027e3526..f8f44265e5b 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -42,6 +42,7 @@ import { RenameWorkflow, RestoreResource, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -87,6 +88,7 @@ import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/han import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' +import { executeRunCode } from '../tools/handlers/run-code' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' import { executeCreateWorkflow, @@ -188,6 +190,7 @@ function buildHandlerMap(): Record { [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), + [RunCode.id]: h(executeRunCode), ...buildServerToolHandlers(), } diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts new file mode 100644 index 00000000000..e27babc4512 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/run-code.ts @@ -0,0 +1,31 @@ +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' + +/** + * Compute-only variant of function_execute for info-gathering agents: same + * sandbox and inputs, but it must never create or overwrite workspace + * resources. The write vectors (outputs.files, outputTable) are rejected here + * on top of the Go executor's fail-fast guard; run_code is also absent from + * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a + * leaked arg could not write anything. + */ +export async function executeRunCode( + params: Record, + context: ToolExecutionContext +): Promise { + if ('outputs' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead', + } + } + if ('outputTable' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', + } + } + return executeFunctionExecute(params, context) +} diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts new file mode 100644 index 00000000000..8b22ee7d7a4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts @@ -0,0 +1,39 @@ +import { SearchKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' + +type SearchKnowledgeBaseArgs = { + operation: string + args?: Record +} + +type SearchKnowledgeBaseResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'query', 'list_tags']) + +/** + * Read-only variant of knowledge_base for info-gathering agents. Copilot + * access control is a per-agent tool allowlist, so read-only access gets its + * own tool name with its own operation contract — enforced here (where + * execution happens) on top of the fail-fast guard in the Go executor. + */ +export const searchKnowledgeBaseServerTool: BaseServerTool< + SearchKnowledgeBaseArgs, + SearchKnowledgeBaseResult +> = { + name: SearchKnowledgeBase.id, + async execute(params: SearchKnowledgeBaseArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's knowledge_base tool`, + } + } + return knowledgeBaseServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 7780f97eb73..74824b53f25 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -49,10 +49,12 @@ import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generat import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' +import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' @@ -150,8 +152,10 @@ const baseServerToolRegistry: Record = { [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, + [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, + [queryUserTableServerTool.name]: queryUserTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts new file mode 100644 index 00000000000..e2b07176da5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts @@ -0,0 +1,51 @@ +import { QueryUserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type QueryUserTableArgs = { + operation: string + args?: Record +} + +type QueryUserTableResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) + +/** + * Read-only variant of user_table for info-gathering agents. Copilot access + * control is a per-agent tool allowlist, so read-only access gets its own tool + * name with its own operation contract — enforced here (where execution + * happens) on top of the fail-fast guard in the Go executor. outputPath is + * rejected because query_rows exports rows to a workspace file through it. + */ +export const queryUserTableServerTool: BaseServerTool = { + name: QueryUserTable.id, + async execute(params: QueryUserTableArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `query_user_table is read-only: operation '${operation}' is not available (allowed: get, get_row, get_schema, query_rows); mutations go through the table agent's user_table tool`, + } + } + if (params?.args && 'outputPath' in params.args) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + if (params && 'outputPath' in (params as Record)) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5ee25a69959..f9bb09014c0 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -74,13 +74,15 @@ function workspaceFileTitle(args: ToolArgs): string { const TOOL_TITLES: Record = { read: 'Reading file', search_library_docs: 'Searching library docs', - user_memory: 'Accessing memory', user_table: 'Managing table', + run_code: 'Running code', + query_user_table: 'Querying table', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', knowledge_base: 'Managing knowledge base', + search_knowledge_base: 'Searching knowledge base', open_resource: 'Opening resource', generate_image: 'Generating image', generate_video: 'Generating video', @@ -97,6 +99,8 @@ const TOOL_TITLES: Record = { scheduled_task: 'Scheduled Task Agent', agent: 'Tools Agent', research: 'Research Agent', + scout: 'Scout Agent', + search: 'Search Agent', media: 'Media Agent', superagent: 'Executing action', } diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index fedc7d65fec..b9b3d37165c 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -7,7 +7,7 @@ export { CalendarDayCell, type CalendarDayCellProps, } from './calendar/calendar-day-cell' -export { Checkbox } from './checkbox/checkbox' +export { Checkbox, checkboxIconVariants, checkboxVariants } from './checkbox/checkbox' export { Chip, ChipLink, diff --git a/packages/emcn/src/icons/chevron-left.tsx b/packages/emcn/src/icons/chevron-left.tsx new file mode 100644 index 00000000000..f79b8280e7f --- /dev/null +++ b/packages/emcn/src/icons/chevron-left.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * ChevronLeft icon component + * @param props - SVG properties including className, fill, etc. + */ +export function ChevronLeft(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/chevron-right.tsx b/packages/emcn/src/icons/chevron-right.tsx new file mode 100644 index 00000000000..2f2a591ae87 --- /dev/null +++ b/packages/emcn/src/icons/chevron-right.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * ChevronRight icon component + * @param props - SVG properties including className, fill, etc. + */ +export function ChevronRight(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 1af0459200b..9879a0c84e5 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -15,6 +15,8 @@ export { Calendar } from './calendar' export { Card } from './card' export { Check } from './check' export { ChevronDown } from './chevron-down' +export { ChevronLeft } from './chevron-left' +export { ChevronRight } from './chevron-right' export { CircleAlert } from './circle-alert' export { CircleCheck } from './circle-check' export { CircleInfo } from './circle-info'