Tool call names

This commit is contained in:
Siddharth Ganesan
2026-04-09 17:15:45 -07:00
parent 2d2f7828c9
commit 485dce7bed
8 changed files with 976 additions and 284 deletions

View File

@@ -92,7 +92,7 @@ function mapToolStatusToClientState(
}
function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): string | undefined {
if (tc.name === ReadTool.id || tc.name.endsWith('_respond')) {
if (tc.name === ReadTool.id || tc.name === 'respond' || tc.name.endsWith('_respond')) {
return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.id, tc.params)
?.text
}

View File

@@ -20,6 +20,7 @@ import {
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
CrawlWebsite,
CreateFolder,
DeleteFolder,
DeleteWorkflow,
@@ -27,13 +28,33 @@ import {
DeployChat,
DeployMcp,
File as FileTool,
GetPageContents,
GetWorkflowLogs,
Glob,
Grep,
ManageCredential,
ManageCredentialOperation,
ManageCustomTool,
ManageCustomToolOperation,
ManageJob,
ManageJobOperation,
ManageMcpTool,
ManageMcpToolOperation,
ManageSkill,
ManageSkillOperation,
MoveFolder,
MoveWorkflow,
Read as ReadTool,
Redeploy,
RenameWorkflow,
RunFromBlock,
RunWorkflow,
RunWorkflowUntilBlock,
ScrapePage,
SearchOnline,
ToolSearchToolRegex,
WorkspaceFile,
WorkspaceFileOperation,
} from '@/lib/copilot/generated/tool-catalog-v1'
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
import {
@@ -72,6 +93,7 @@ import type { ChatContext } from '@/stores/panel'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type {
ChatMessage,
ContentBlock,
@@ -132,6 +154,314 @@ const logger = createLogger('useChat')
type StreamPayload = Record<string, unknown>
function stringParam(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function stringArrayParam(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined {
const id = stringParam(workflowId)
if (!id) return undefined
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return undefined
return getWorkflowById(workspaceId, id)?.name
}
function resolveBlockNameForDisplay(blockId: unknown): string | undefined {
const id = stringParam(blockId)
if (!id) return undefined
return useWorkflowStore.getState().blocks[id]?.name
}
function resolveWorkspaceFileDisplayTitle(
operation: unknown,
title: unknown,
targetFileName?: unknown
): string | undefined {
const chunkTitle = stringParam(title)
const fileName = stringParam(targetFileName)
let verb = 'Writing'
switch (operation) {
case WorkspaceFileOperation.append:
verb = 'Adding'
break
case WorkspaceFileOperation.patch:
verb = 'Editing'
break
case WorkspaceFileOperation.update:
verb = 'Writing'
break
}
if (chunkTitle) return `${verb} ${chunkTitle}`
if (fileName) return `${verb} ${fileName}`
return undefined
}
function resolveOperationDisplayTitle(
operation: unknown,
labels: Partial<Record<string, string>>,
fallback: string
): string {
const label = typeof operation === 'string' ? labels[operation] : undefined
return label ?? fallback
}
function resolveToolDisplayTitle(name: string, args?: Record<string, unknown>): string | undefined {
if (!args) return undefined
if (name === WorkspaceFile.id) {
const target = asPayloadRecord(args.target)
return resolveWorkspaceFileDisplayTitle(args.operation, args.title, target?.fileName)
}
if (name === SearchOnline.id) {
const toolTitle = stringParam(args.toolTitle)
return toolTitle ? `Searching online for ${toolTitle}` : 'Searching online'
}
if (name === Grep.id) {
const toolTitle = stringParam(args.toolTitle)
return toolTitle ? `Searching for ${toolTitle}` : 'Searching'
}
if (name === Glob.id) {
const toolTitle = stringParam(args.toolTitle)
return toolTitle ? `Finding ${toolTitle}` : 'Finding files'
}
if (name === ScrapePage.id) {
const url = stringParam(args.url)
return url ? `Scraping ${url}` : 'Scraping page'
}
if (name === CrawlWebsite.id) {
const url = stringParam(args.url)
return url ? `Crawling ${url}` : 'Crawling website'
}
if (name === GetPageContents.id) {
const urls = stringArrayParam(args.urls)
if (urls.length === 1) return `Getting ${urls[0]}`
if (urls.length > 1) return `Getting ${urls.length} pages`
return 'Getting page contents'
}
if (name === ManageCustomTool.id) {
return resolveOperationDisplayTitle(
args.operation,
{
[ManageCustomToolOperation.add]: 'Creating custom tool',
[ManageCustomToolOperation.edit]: 'Updating custom tool',
[ManageCustomToolOperation.delete]: 'Deleting custom tool',
[ManageCustomToolOperation.list]: 'Listing custom tools',
},
'Custom tool action'
)
}
if (name === ManageMcpTool.id) {
return resolveOperationDisplayTitle(
args.operation,
{
[ManageMcpToolOperation.add]: 'Creating MCP server',
[ManageMcpToolOperation.edit]: 'Updating MCP server',
[ManageMcpToolOperation.delete]: 'Deleting MCP server',
[ManageMcpToolOperation.list]: 'Listing MCP servers',
},
'MCP server action'
)
}
if (name === ManageSkill.id) {
return resolveOperationDisplayTitle(
args.operation,
{
[ManageSkillOperation.add]: 'Creating skill',
[ManageSkillOperation.edit]: 'Updating skill',
[ManageSkillOperation.delete]: 'Deleting skill',
[ManageSkillOperation.list]: 'Listing skills',
},
'Skill action'
)
}
if (name === ManageJob.id) {
return resolveOperationDisplayTitle(
args.operation,
{
[ManageJobOperation.create]: 'Creating job',
[ManageJobOperation.get]: 'Getting job',
[ManageJobOperation.update]: 'Updating job',
[ManageJobOperation.delete]: 'Deleting job',
[ManageJobOperation.list]: 'Listing jobs',
},
'Job action'
)
}
if (name === ManageCredential.id) {
return resolveOperationDisplayTitle(
args.operation,
{
[ManageCredentialOperation.rename]: 'Renaming credential',
[ManageCredentialOperation.delete]: 'Deleting credential',
},
'Credential action'
)
}
if (name === RunWorkflow.id) {
const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
return workflowName ? `Running ${workflowName}` : 'Running workflow'
}
if (name === RunFromBlock.id) {
const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
const blockName = resolveBlockNameForDisplay(args.startBlockId)
if (workflowName && blockName) return `Running ${workflowName} from ${blockName}`
if (workflowName) return `Running ${workflowName}`
if (blockName) return `Running from ${blockName}`
return 'Running workflow'
}
if (name === RunWorkflowUntilBlock.id) {
const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
const blockName = resolveBlockNameForDisplay(args.stopAfterBlockId)
if (workflowName && blockName) return `Running ${workflowName} until ${blockName}`
if (workflowName) return `Running ${workflowName}`
if (blockName) return `Running until ${blockName}`
return 'Running workflow'
}
if (name === GetWorkflowLogs.id) {
const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
return workflowName ? `Getting logs for ${workflowName}` : 'Getting logs'
}
return undefined
}
function decodeStreamingString(value: string): string {
return value
.replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16))
)
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
}
function matchStreamingStringArg(streamingArgs: string, key: string): string | undefined {
const match = streamingArgs.match(new RegExp(`"${key}"\\s*:\\s*"([^"]*)"`, 'm'))
return match?.[1] ? decodeStreamingString(match[1]) : undefined
}
function resolveStreamingToolDisplayTitle(name: string, streamingArgs: string): string | undefined {
if (name === WorkspaceFile.id) {
return resolveWorkspaceFileDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
matchStreamingStringArg(streamingArgs, 'title'),
matchStreamingStringArg(streamingArgs, 'fileName')
)
}
if (name === SearchOnline.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Searching online for ${toolTitle}` : undefined
}
if (name === Grep.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Searching for ${toolTitle}` : undefined
}
if (name === Glob.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Finding ${toolTitle}` : undefined
}
if (name === ScrapePage.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Scraping ${url}` : undefined
}
if (name === CrawlWebsite.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Crawling ${url}` : undefined
}
if (name === ManageCustomTool.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageCustomToolOperation.add]: 'Creating custom tool',
[ManageCustomToolOperation.edit]: 'Updating custom tool',
[ManageCustomToolOperation.delete]: 'Deleting custom tool',
[ManageCustomToolOperation.list]: 'Listing custom tools',
},
'Custom tool action'
)
}
if (name === ManageMcpTool.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageMcpToolOperation.add]: 'Creating MCP server',
[ManageMcpToolOperation.edit]: 'Updating MCP server',
[ManageMcpToolOperation.delete]: 'Deleting MCP server',
[ManageMcpToolOperation.list]: 'Listing MCP servers',
},
'MCP server action'
)
}
if (name === ManageSkill.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageSkillOperation.add]: 'Creating skill',
[ManageSkillOperation.edit]: 'Updating skill',
[ManageSkillOperation.delete]: 'Deleting skill',
[ManageSkillOperation.list]: 'Listing skills',
},
'Skill action'
)
}
if (name === ManageJob.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageJobOperation.create]: 'Creating job',
[ManageJobOperation.get]: 'Getting job',
[ManageJobOperation.update]: 'Updating job',
[ManageJobOperation.delete]: 'Deleting job',
[ManageJobOperation.list]: 'Listing jobs',
},
'Job action'
)
}
if (name === ManageCredential.id) {
return resolveOperationDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
{
[ManageCredentialOperation.rename]: 'Renaming credential',
[ManageCredentialOperation.delete]: 'Deleting credential',
},
'Credential action'
)
}
return undefined
}
type StreamToolUI = {
hidden?: boolean
title?: string
@@ -1068,35 +1398,8 @@ export function useChat(
if (idx !== undefined && blocks[idx].toolCall) {
const tc = blocks[idx].toolCall!
tc.streamingArgs = (tc.streamingArgs ?? '') + delta
if (tc.name === WorkspaceFile.id) {
const opMatch = tc.streamingArgs.match(/"operation"\s*:\s*"(\w+)"/)
const op = opMatch?.[1] ?? ''
const verb =
op === 'create'
? 'Creating'
: op === 'append'
? 'Adding'
: op === 'patch'
? 'Editing'
: op === 'update'
? 'Writing'
: op === 'rename'
? 'Renaming'
: op === 'delete'
? 'Deleting'
: 'Writing'
const titleMatch = tc.streamingArgs.match(/"title"\s*:\s*"([^"]*)"/)
if (titleMatch?.[1]) {
const unescaped = titleMatch[1]
.replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16))
)
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
tc.displayTitle = `${verb} ${unescaped}`
}
}
const displayTitle = resolveStreamingToolDisplayTitle(tc.name, tc.streamingArgs)
if (displayTitle) tc.displayTitle = displayTitle
flush()
}
@@ -1252,31 +1555,7 @@ export function useChat(
| Record<string, unknown>
| undefined
if (name === WorkspaceFile.id) {
const operation = typeof args?.operation === 'string' ? args.operation : ''
const verb =
operation === 'create'
? 'Creating'
: operation === 'append'
? 'Adding'
: operation === 'patch'
? 'Editing'
: operation === 'update'
? 'Writing'
: operation === 'rename'
? 'Renaming'
: operation === 'delete'
? 'Deleting'
: 'Writing'
const chunkTitle = args?.title as string | undefined
const target = args ? asPayloadRecord(args.target) : undefined
const targetFileName = target?.fileName as string | undefined
if (chunkTitle) {
displayTitle = `${verb} ${chunkTitle}`
} else if (targetFileName) {
displayTitle = `${verb} ${targetFileName}`
}
}
displayTitle = resolveToolDisplayTitle(name, args) ?? displayTitle
if (name === 'edit_content') {
const parentToolCallId =

View File

@@ -180,19 +180,19 @@ export interface ChatMessage {
}
export const SUBAGENT_LABELS: Record<string, string> = {
workflow: 'Workflow agent',
deploy: 'Deploy agent',
auth: 'Integration agent',
research: 'Research agent',
knowledge: 'Knowledge agent',
table: 'Table agent',
custom_tool: 'Custom Tool agent',
workflow: 'Workflow Agent',
deploy: 'Deploy Agent',
auth: 'Auth Agent',
research: 'Research Agent',
knowledge: 'Knowledge Agent',
table: 'Table Agent',
custom_tool: 'Custom Tool Agent',
superagent: 'Superagent',
debug: 'Debug agent',
run: 'Run agent',
agent: 'Agent manager',
job: 'Job agent',
file: 'File',
debug: 'Debug Agent',
run: 'Run Agent',
agent: 'Tools Agent',
job: 'Job Agent',
file: 'File Agent',
} as const
export interface ToolUIMetadata {
@@ -208,12 +208,12 @@ export interface ToolUIMetadata {
*/
export const TOOL_UI_METADATA: Record<string, ToolUIMetadata> = {
[Glob.id]: {
title: 'Searching files',
title: 'Finding files',
phaseLabel: 'Workspace',
phase: 'workspace',
},
[Grep.id]: {
title: 'Searching code',
title: 'Searching',
phaseLabel: 'Workspace',
phase: 'workspace',
},
@@ -239,12 +239,12 @@ export const TOOL_UI_METADATA: Record<string, ToolUIMetadata> = {
phase: 'search',
},
[ManageMcpTool.id]: {
title: 'Managing MCP tool',
title: 'MCP server action',
phaseLabel: 'Management',
phase: 'management',
},
[ManageSkill.id]: {
title: 'Managing skill',
title: 'Skill action',
phaseLabel: 'Management',
phase: 'management',
},
@@ -288,16 +288,16 @@ export const TOOL_UI_METADATA: Record<string, ToolUIMetadata> = {
phaseLabel: 'Resource',
phase: 'resource',
},
[Workflow.id]: { title: 'Managing workflow', phaseLabel: 'Workflow', phase: 'subagent' },
[Run.id]: { title: 'Running', phaseLabel: 'Run', phase: 'subagent' },
[Deploy.id]: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' },
[Workflow.id]: { title: 'Workflow Agent', phaseLabel: 'Workflow', phase: 'subagent' },
[Run.id]: { title: 'Run Agent', phaseLabel: 'Run', phase: 'subagent' },
[Deploy.id]: { title: 'Deploy Agent', phaseLabel: 'Deploy', phase: 'subagent' },
[Auth.id]: {
title: 'Connecting credentials',
title: 'Auth Agent',
phaseLabel: 'Auth',
phase: 'subagent',
},
[Knowledge.id]: {
title: 'Managing knowledge',
title: 'Knowledge Agent',
phaseLabel: 'Knowledge',
phase: 'subagent',
},
@@ -306,16 +306,16 @@ export const TOOL_UI_METADATA: Record<string, ToolUIMetadata> = {
phaseLabel: 'Resource',
phase: 'resource',
},
[Table.id]: { title: 'Managing tables', phaseLabel: 'Table', phase: 'subagent' },
[Job.id]: { title: 'Managing jobs', phaseLabel: 'Job', phase: 'subagent' },
[Agent.id]: { title: 'Agent action', phaseLabel: 'Agent', phase: 'subagent' },
[Table.id]: { title: 'Table Agent', phaseLabel: 'Table', phase: 'subagent' },
[Job.id]: { title: 'Job Agent', phaseLabel: 'Job', phase: 'subagent' },
[Agent.id]: { title: 'Tools Agent', phaseLabel: 'Agent', phase: 'subagent' },
custom_tool: {
title: 'Creating tool',
phaseLabel: 'Tool',
phase: 'subagent',
},
[Research.id]: { title: 'Researching', phaseLabel: 'Research', phase: 'subagent' },
[Debug.id]: { title: 'Debugging', phaseLabel: 'Debug', phase: 'subagent' },
[Research.id]: { title: 'Research Agent', phaseLabel: 'Research', phase: 'subagent' },
[Debug.id]: { title: 'Debug Agent', phaseLabel: 'Debug', phase: 'subagent' },
[OpenResource.id]: {
title: 'Opening resource',
phaseLabel: 'Resource',

View File

@@ -701,6 +701,38 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = {
requiredPermission: 'write',
}
export const EditContent: ToolCatalogEntry = {
id: 'edit_content',
name: 'edit_content',
executor: 'sim',
mode: 'async',
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description:
'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
},
},
required: ['content'],
},
resultSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description:
'Optional operation metadata such as file id, file name, size, and content type.',
},
message: { type: 'string', description: 'Human-readable summary of the outcome.' },
success: { type: 'boolean', description: 'Whether the content was applied successfully.' },
},
required: ['success', 'message'],
},
requiredPermission: 'write',
}
export const EditWorkflow: ToolCatalogEntry = {
id: 'edit_workflow',
name: 'edit_workflow',
@@ -1138,10 +1170,10 @@ export const Glob: ToolCatalogEntry = {
description:
'Glob pattern to match file paths. Supports * (any segment) and ** (any depth).',
},
title: {
toolTitle: {
type: 'string',
description:
"Short human-readable label shown in the UI while this search runs (e.g. 'Finding workflow configs', 'Listing knowledge bases').",
'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".',
},
},
required: ['pattern'],
@@ -1183,10 +1215,10 @@ export const Grep: ToolCatalogEntry = {
"Optional path prefix to scope the search (e.g. 'workflows/', 'environment/', 'internal/', 'components/blocks/').",
},
pattern: { type: 'string', description: 'Regex pattern to search for in file contents.' },
title: {
toolTitle: {
type: 'string',
description:
"Short human-readable label shown in the UI while this search runs (e.g. 'Searching Slack integrations', 'Finding deployed workflows').",
'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".',
},
},
required: ['pattern'],
@@ -2170,6 +2202,11 @@ export const SearchOnline: ToolCatalogEntry = {
include_text: { type: 'boolean', description: 'Include page text content (default true)' },
num_results: { type: 'number', description: 'Number of results (default 10, max 25)' },
query: { type: 'string', description: 'Natural language search query' },
toolTitle: {
type: 'string',
description:
'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "pricing changes" or "Slack webhook docs", not a full sentence like "Searching online for pricing changes".',
},
},
required: ['query'],
},
@@ -2593,11 +2630,6 @@ export const WorkspaceFile: ToolCatalogEntry = {
type: 'object',
description: 'Explicit file target. Use kind=file_id + fileId for existing files.',
properties: {
kind: {
type: 'string',
description: 'How the file target is identified.',
enum: ['new_file', 'file_id'],
},
fileId: {
type: 'string',
description: 'Canonical existing workspace file ID. Required when target.kind=file_id.',
@@ -2607,6 +2639,11 @@ export const WorkspaceFile: ToolCatalogEntry = {
description:
'Plain workspace filename including extension, e.g. "main.py" or "report.docx". Required when target.kind=new_file.',
},
kind: {
type: 'string',
description: 'How the file target is identified.',
enum: ['new_file', 'file_id'],
},
},
required: ['kind'],
},
@@ -2635,35 +2672,6 @@ export const WorkspaceFile: ToolCatalogEntry = {
description:
'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired edit_content tool call.',
properties: {
strategy: {
type: 'string',
description: 'Patch strategy.',
enum: ['search_replace', 'anchored'],
},
search: {
type: 'string',
description:
'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.',
},
replaceAll: {
type: 'boolean',
description:
'When true and strategy=search_replace, replace every match instead of requiring a unique single match.',
},
mode: {
type: 'string',
description: 'Anchored edit mode when strategy=anchored.',
enum: ['replace_between', 'insert_after', 'delete_between'],
},
occurrence: {
type: 'number',
description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.',
},
before_anchor: {
type: 'string',
description:
'Boundary line kept before inserted replacement content. Required for mode=replace_between.',
},
after_anchor: {
type: 'string',
description:
@@ -2674,16 +2682,49 @@ export const WorkspaceFile: ToolCatalogEntry = {
description:
'Anchor line after which new content is inserted. Required for mode=insert_after.',
},
start_anchor: {
before_anchor: {
type: 'string',
description: 'First line to delete. Required for mode=delete_between.',
description:
'Boundary line kept before inserted replacement content. Required for mode=replace_between.',
},
end_anchor: {
type: 'string',
description: 'First line to keep after deletion. Required for mode=delete_between.',
},
mode: {
type: 'string',
description: 'Anchored edit mode when strategy=anchored.',
enum: ['replace_between', 'insert_after', 'delete_between'],
},
occurrence: {
type: 'number',
description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.',
},
replaceAll: {
type: 'boolean',
description:
'When true and strategy=search_replace, replace every match instead of requiring a unique single match.',
},
search: {
type: 'string',
description:
'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.',
},
start_anchor: {
type: 'string',
description: 'First line to delete. Required for mode=delete_between.',
},
strategy: {
type: 'string',
description: 'Patch strategy.',
enum: ['search_replace', 'anchored'],
},
},
},
newName: {
type: 'string',
description: 'New file name for rename. Must be a plain workspace filename like "main.py".',
},
},
required: ['operation', 'target', 'title'],
},
@@ -2703,37 +2744,227 @@ export const WorkspaceFile: ToolCatalogEntry = {
requiredPermission: 'write',
}
export const EditContent: ToolCatalogEntry = {
id: 'edit_content',
name: 'edit_content',
executor: 'sim',
mode: 'async',
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description:
'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
},
},
required: ['content'],
},
resultSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description:
'Optional operation metadata such as file id, file name, size, and content type.',
},
message: { type: 'string', description: 'Human-readable summary of the outcome.' },
success: { type: 'boolean', description: 'Whether the content was applied successfully.' },
},
required: ['success', 'message'],
},
requiredPermission: 'write',
}
export const KnowledgeBaseOperation = {
create: 'create',
get: 'get',
query: 'query',
addFile: 'add_file',
update: 'update',
delete: 'delete',
deleteDocument: 'delete_document',
updateDocument: 'update_document',
listTags: 'list_tags',
createTag: 'create_tag',
updateTag: 'update_tag',
deleteTag: 'delete_tag',
getTagUsage: 'get_tag_usage',
addConnector: 'add_connector',
updateConnector: 'update_connector',
deleteConnector: 'delete_connector',
syncConnector: 'sync_connector',
} as const
export type KnowledgeBaseOperation =
(typeof KnowledgeBaseOperation)[keyof typeof KnowledgeBaseOperation]
export const KnowledgeBaseOperationValues = [
KnowledgeBaseOperation.create,
KnowledgeBaseOperation.get,
KnowledgeBaseOperation.query,
KnowledgeBaseOperation.addFile,
KnowledgeBaseOperation.update,
KnowledgeBaseOperation.delete,
KnowledgeBaseOperation.deleteDocument,
KnowledgeBaseOperation.updateDocument,
KnowledgeBaseOperation.listTags,
KnowledgeBaseOperation.createTag,
KnowledgeBaseOperation.updateTag,
KnowledgeBaseOperation.deleteTag,
KnowledgeBaseOperation.getTagUsage,
KnowledgeBaseOperation.addConnector,
KnowledgeBaseOperation.updateConnector,
KnowledgeBaseOperation.deleteConnector,
KnowledgeBaseOperation.syncConnector,
] as const
export const ManageCredentialOperation = {
rename: 'rename',
delete: 'delete',
} as const
export type ManageCredentialOperation =
(typeof ManageCredentialOperation)[keyof typeof ManageCredentialOperation]
export const ManageCredentialOperationValues = [
ManageCredentialOperation.rename,
ManageCredentialOperation.delete,
] as const
export const ManageCustomToolOperation = {
add: 'add',
edit: 'edit',
delete: 'delete',
list: 'list',
} as const
export type ManageCustomToolOperation =
(typeof ManageCustomToolOperation)[keyof typeof ManageCustomToolOperation]
export const ManageCustomToolOperationValues = [
ManageCustomToolOperation.add,
ManageCustomToolOperation.edit,
ManageCustomToolOperation.delete,
ManageCustomToolOperation.list,
] as const
export const ManageJobOperation = {
create: 'create',
list: 'list',
get: 'get',
update: 'update',
delete: 'delete',
} as const
export type ManageJobOperation = (typeof ManageJobOperation)[keyof typeof ManageJobOperation]
export const ManageJobOperationValues = [
ManageJobOperation.create,
ManageJobOperation.list,
ManageJobOperation.get,
ManageJobOperation.update,
ManageJobOperation.delete,
] as const
export const ManageMcpToolOperation = {
add: 'add',
edit: 'edit',
delete: 'delete',
list: 'list',
} as const
export type ManageMcpToolOperation =
(typeof ManageMcpToolOperation)[keyof typeof ManageMcpToolOperation]
export const ManageMcpToolOperationValues = [
ManageMcpToolOperation.add,
ManageMcpToolOperation.edit,
ManageMcpToolOperation.delete,
ManageMcpToolOperation.list,
] as const
export const ManageSkillOperation = {
add: 'add',
edit: 'edit',
delete: 'delete',
list: 'list',
} as const
export type ManageSkillOperation = (typeof ManageSkillOperation)[keyof typeof ManageSkillOperation]
export const ManageSkillOperationValues = [
ManageSkillOperation.add,
ManageSkillOperation.edit,
ManageSkillOperation.delete,
ManageSkillOperation.list,
] as const
export const MaterializeFileOperation = {
save: 'save',
import: 'import',
table: 'table',
knowledgeBase: 'knowledge_base',
} as const
export type MaterializeFileOperation =
(typeof MaterializeFileOperation)[keyof typeof MaterializeFileOperation]
export const MaterializeFileOperationValues = [
MaterializeFileOperation.save,
MaterializeFileOperation.import,
MaterializeFileOperation.table,
MaterializeFileOperation.knowledgeBase,
] as const
export const UserMemoryOperation = {
add: 'add',
search: 'search',
delete: 'delete',
correct: 'correct',
list: 'list',
} as const
export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation]
export const UserMemoryOperationValues = [
UserMemoryOperation.add,
UserMemoryOperation.search,
UserMemoryOperation.delete,
UserMemoryOperation.correct,
UserMemoryOperation.list,
] as const
export const UserTableOperation = {
create: 'create',
createFromFile: 'create_from_file',
importFile: 'import_file',
get: 'get',
getSchema: 'get_schema',
delete: 'delete',
insertRow: 'insert_row',
batchInsertRows: 'batch_insert_rows',
getRow: 'get_row',
queryRows: 'query_rows',
updateRow: 'update_row',
deleteRow: 'delete_row',
updateRowsByFilter: 'update_rows_by_filter',
deleteRowsByFilter: 'delete_rows_by_filter',
batchUpdateRows: 'batch_update_rows',
batchDeleteRows: 'batch_delete_rows',
addColumn: 'add_column',
renameColumn: 'rename_column',
deleteColumn: 'delete_column',
updateColumn: 'update_column',
} as const
export type UserTableOperation = (typeof UserTableOperation)[keyof typeof UserTableOperation]
export const UserTableOperationValues = [
UserTableOperation.create,
UserTableOperation.createFromFile,
UserTableOperation.importFile,
UserTableOperation.get,
UserTableOperation.getSchema,
UserTableOperation.delete,
UserTableOperation.insertRow,
UserTableOperation.batchInsertRows,
UserTableOperation.getRow,
UserTableOperation.queryRows,
UserTableOperation.updateRow,
UserTableOperation.deleteRow,
UserTableOperation.updateRowsByFilter,
UserTableOperation.deleteRowsByFilter,
UserTableOperation.batchUpdateRows,
UserTableOperation.batchDeleteRows,
UserTableOperation.addColumn,
UserTableOperation.renameColumn,
UserTableOperation.deleteColumn,
UserTableOperation.updateColumn,
] as const
export const WorkspaceFileOperation = {
append: 'append',
update: 'update',
patch: 'patch',
} as const
export type WorkspaceFileOperation =
(typeof WorkspaceFileOperation)[keyof typeof WorkspaceFileOperation]
export const WorkspaceFileOperationValues = [
WorkspaceFileOperation.append,
WorkspaceFileOperation.update,
WorkspaceFileOperation.patch,
] as const
export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
[Agent.id]: Agent,
@@ -2757,6 +2988,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
[DeployChat.id]: DeployChat,
[DeployMcp.id]: DeployMcp,
[DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile,
[EditContent.id]: EditContent,
[EditWorkflow.id]: EditWorkflow,
[File.id]: File,
[FunctionExecute.id]: FunctionExecute,
@@ -2820,5 +3052,4 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
[UserTable.id]: UserTable,
[Workflow.id]: Workflow,
[WorkspaceFile.id]: WorkspaceFile,
[EditContent.id]: EditContent,
}

View File

@@ -513,6 +513,38 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
resultSchema: undefined,
},
edit_content: {
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description:
'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
},
},
required: ['content'],
},
resultSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description:
'Optional operation metadata such as file id, file name, size, and content type.',
},
message: {
type: 'string',
description: 'Human-readable summary of the outcome.',
},
success: {
type: 'boolean',
description: 'Whether the content was applied successfully.',
},
},
required: ['success', 'message'],
},
},
edit_workflow: {
parameters: {
type: 'object',
@@ -928,10 +960,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
description:
'Glob pattern to match file paths. Supports * (any segment) and ** (any depth).',
},
title: {
toolTitle: {
type: 'string',
description:
"Short human-readable label shown in the UI while this search runs (e.g. 'Finding workflow configs', 'Listing knowledge bases').",
'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".',
},
},
required: ['pattern'],
@@ -975,10 +1007,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
type: 'string',
description: 'Regex pattern to search for in file contents.',
},
title: {
toolTitle: {
type: 'string',
description:
"Short human-readable label shown in the UI while this search runs (e.g. 'Searching Slack integrations', 'Finding deployed workflows').",
'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".',
},
},
required: ['pattern'],
@@ -1936,6 +1968,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
type: 'string',
description: 'Natural language search query',
},
toolTitle: {
type: 'string',
description:
'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "pricing changes" or "Slack webhook docs", not a full sentence like "Searching online for pricing changes".',
},
},
required: ['query'],
},
@@ -2361,11 +2398,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
type: 'object',
description: 'Explicit file target. Use kind=file_id + fileId for existing files.',
properties: {
kind: {
type: 'string',
description: 'How the file target is identified.',
enum: ['new_file', 'file_id'],
},
fileId: {
type: 'string',
description:
@@ -2376,6 +2408,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
description:
'Plain workspace filename including extension, e.g. "main.py" or "report.docx". Required when target.kind=new_file.',
},
kind: {
type: 'string',
description: 'How the file target is identified.',
enum: ['new_file', 'file_id'],
},
},
required: ['kind'],
},
@@ -2404,35 +2441,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
description:
'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired edit_content tool call.',
properties: {
strategy: {
type: 'string',
description: 'Patch strategy.',
enum: ['search_replace', 'anchored'],
},
search: {
type: 'string',
description:
'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.',
},
replaceAll: {
type: 'boolean',
description:
'When true and strategy=search_replace, replace every match instead of requiring a unique single match.',
},
mode: {
type: 'string',
description: 'Anchored edit mode when strategy=anchored.',
enum: ['replace_between', 'insert_after', 'delete_between'],
},
occurrence: {
type: 'number',
description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.',
},
before_anchor: {
type: 'string',
description:
'Boundary line kept before inserted replacement content. Required for mode=replace_between.',
},
after_anchor: {
type: 'string',
description:
@@ -2443,16 +2451,50 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
description:
'Anchor line after which new content is inserted. Required for mode=insert_after.',
},
start_anchor: {
before_anchor: {
type: 'string',
description: 'First line to delete. Required for mode=delete_between.',
description:
'Boundary line kept before inserted replacement content. Required for mode=replace_between.',
},
end_anchor: {
type: 'string',
description: 'First line to keep after deletion. Required for mode=delete_between.',
},
mode: {
type: 'string',
description: 'Anchored edit mode when strategy=anchored.',
enum: ['replace_between', 'insert_after', 'delete_between'],
},
occurrence: {
type: 'number',
description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.',
},
replaceAll: {
type: 'boolean',
description:
'When true and strategy=search_replace, replace every match instead of requiring a unique single match.',
},
search: {
type: 'string',
description:
'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.',
},
start_anchor: {
type: 'string',
description: 'First line to delete. Required for mode=delete_between.',
},
strategy: {
type: 'string',
description: 'Patch strategy.',
enum: ['search_replace', 'anchored'],
},
},
},
newName: {
type: 'string',
description:
'New file name for rename. Must be a plain workspace filename like "main.py".',
},
},
required: ['operation', 'target', 'title'],
},
@@ -2476,36 +2518,4 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
required: ['success', 'message'],
},
},
edit_content: {
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description:
'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.',
},
},
required: ['content'],
},
resultSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description:
'Optional operation metadata such as file id, file name, size, and content type.',
},
message: {
type: 'string',
description: 'Human-readable summary of the outcome.',
},
success: {
type: 'boolean',
description: 'Whether the content was applied successfully.',
},
},
required: ['success', 'message'],
},
},
}

View File

@@ -35,6 +35,7 @@ const logger = createLogger('CopilotStoreUtils')
/** Respond tools are internal handoff tools shown with a friendly generic label. */
const HIDDEN_TOOL_SUFFIX = '_respond'
const INTERNAL_RESPOND_TOOL = 'respond'
const HIDDEN_TOOL_NAMES = new Set(['tool_search_tool_regex'])
/** UI metadata sent by the copilot on SSE tool_call events. */
@@ -127,7 +128,7 @@ function specialToolDisplay(
state: ClientToolCallState,
params?: Record<string, unknown>
): ClientToolDisplay | undefined {
if (toolName.endsWith(HIDDEN_TOOL_SUFFIX)) {
if (toolName === INTERNAL_RESPOND_TOOL || toolName.endsWith(HIDDEN_TOOL_SUFFIX)) {
return {
text: formatRespondLabel(state),
icon: Loader2,
@@ -146,17 +147,8 @@ function specialToolDisplay(
}
function formatRespondLabel(state: ClientToolCallState): string {
switch (state) {
case ClientToolCallState.success:
return 'Returned results'
case ClientToolCallState.error:
return 'Failed returning results'
case ClientToolCallState.rejected:
case ClientToolCallState.aborted:
return 'Skipped returning results'
default:
return 'Returning results'
}
void state
return 'Gathering thoughts'
}
function readStringParam(

View File

@@ -38,6 +38,11 @@ import {
XCircle,
Zap,
} from 'lucide-react'
import {
ManageCustomToolOperation,
ManageMcpToolOperation,
ManageSkillOperation,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
@@ -227,6 +232,15 @@ const META_check_deployment_status: ToolMetadata = {
interrupt: undefined,
}
const META_complete_job: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Completing job', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Completing job', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed job', icon: CheckCircle },
[ClientToolCallState.error]: { text: 'Failed to complete job', icon: XCircle },
},
}
const META_checkoff_todo: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
@@ -270,6 +284,30 @@ const META_crawl_website: ToolMetadata = {
},
}
const META_create_file: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Creating file', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Creating file', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Created file', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to create file', icon: XCircle },
},
getDynamicText: (params, state) => {
const fileName = params?.fileName
if (typeof fileName !== 'string' || !fileName.trim()) return undefined
switch (state) {
case ClientToolCallState.success:
return `Created ${fileName}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
return `Creating ${fileName}`
case ClientToolCallState.error:
return `Failed to create ${fileName}`
}
return undefined
},
}
const META_create_workspace_mcp_server: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
@@ -348,19 +386,19 @@ const META_agent: ToolMetadata = {
const META_manage_skill: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Managing skill',
text: 'Skill action',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Manage skill?', icon: BookOpen },
[ClientToolCallState.executing]: { text: 'Managing skill', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed skill', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to manage skill', icon: X },
[ClientToolCallState.pending]: { text: 'Skill action?', icon: BookOpen },
[ClientToolCallState.executing]: { text: 'Skill action', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Updated skill', icon: Check },
[ClientToolCallState.error]: { text: 'Failed skill action', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted managing skill',
text: 'Aborted skill action',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped managing skill',
text: 'Skipped skill action',
icon: XCircle,
},
},
@@ -368,6 +406,66 @@ const META_manage_skill: ToolMetadata = {
accept: { text: 'Allow', icon: Check },
reject: { text: 'Deny', icon: X },
},
getDynamicText: (params, state) => {
const operation = params?.operation as ManageSkillOperation | undefined
if (!operation) return undefined
const skillName = typeof params?.name === 'string' ? params.name : 'skill'
switch (state) {
case ClientToolCallState.success:
switch (operation) {
case ManageSkillOperation.add:
return `Created ${skillName}`
case ManageSkillOperation.edit:
return `Updated ${skillName}`
case ManageSkillOperation.delete:
return `Deleted ${skillName}`
case ManageSkillOperation.list:
return 'Listed skills'
}
break
case ClientToolCallState.executing:
case ClientToolCallState.generating:
switch (operation) {
case ManageSkillOperation.add:
return `Creating ${skillName}`
case ManageSkillOperation.edit:
return `Updating ${skillName}`
case ManageSkillOperation.delete:
return `Deleting ${skillName}`
case ManageSkillOperation.list:
return 'Listing skills'
}
break
case ClientToolCallState.pending:
switch (operation) {
case ManageSkillOperation.add:
return `Create ${skillName}?`
case ManageSkillOperation.edit:
return `Update ${skillName}?`
case ManageSkillOperation.delete:
return `Delete ${skillName}?`
case ManageSkillOperation.list:
return 'List skills?'
}
break
case ClientToolCallState.error:
switch (operation) {
case ManageSkillOperation.add:
return `Failed to create ${skillName}`
case ManageSkillOperation.edit:
return `Failed to update ${skillName}`
case ManageSkillOperation.delete:
return `Failed to delete ${skillName}`
case ManageSkillOperation.list:
return 'Failed to list skills'
}
break
}
return undefined
},
}
const META_workflow: ToolMetadata = {
@@ -962,19 +1060,19 @@ const META_list_workspace_mcp_servers: ToolMetadata = {
const META_manage_custom_tool: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Managing custom tool',
text: 'Custom tool action',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Manage custom tool?', icon: Plus },
[ClientToolCallState.executing]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed custom tool', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to manage custom tool', icon: X },
[ClientToolCallState.pending]: { text: 'Custom tool action?', icon: Plus },
[ClientToolCallState.executing]: { text: 'Custom tool action', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Updated custom tool', icon: Check },
[ClientToolCallState.error]: { text: 'Failed custom tool action', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted managing custom tool',
text: 'Aborted custom tool action',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped managing custom tool',
text: 'Skipped custom tool action',
icon: XCircle,
},
},
@@ -983,7 +1081,7 @@ const META_manage_custom_tool: ToolMetadata = {
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
const operation = params?.operation as ManageCustomToolOperation | undefined
const workspaceId = getScopedWorkspaceId(params)
if (!operation) return undefined
@@ -1004,13 +1102,13 @@ const META_manage_custom_tool: ToolMetadata = {
const getActionText = (verb: 'present' | 'past' | 'gerund') => {
switch (operation) {
case 'add':
case ManageCustomToolOperation.add:
return verb === 'present' ? 'Create' : verb === 'past' ? 'Created' : 'Creating'
case 'edit':
case ManageCustomToolOperation.edit:
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete':
case ManageCustomToolOperation.delete:
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
case 'list':
case ManageCustomToolOperation.list:
return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing'
default:
return verb === 'present' ? 'Manage' : verb === 'past' ? 'Managed' : 'Managing'
@@ -1021,15 +1119,15 @@ const META_manage_custom_tool: ToolMetadata = {
// For edit/delete: always show tool name
// For list: never show individual tool name, use plural
const shouldShowToolName = (currentState: ClientToolCallState) => {
if (operation === 'list') return false
if (operation === 'add') {
if (operation === ManageCustomToolOperation.list) return false
if (operation === ManageCustomToolOperation.add) {
return currentState === ClientToolCallState.success
}
return true // edit and delete always show tool name
}
const nameText =
operation === 'list'
operation === ManageCustomToolOperation.list
? ' custom tools'
: shouldShowToolName(state) && toolName
? ` ${toolName}`
@@ -1058,19 +1156,19 @@ const META_manage_custom_tool: ToolMetadata = {
const META_manage_mcp_tool: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Managing MCP tool',
text: 'MCP server action',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Manage MCP tool?', icon: Server },
[ClientToolCallState.executing]: { text: 'Managing MCP tool', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed MCP tool', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to manage MCP tool', icon: X },
[ClientToolCallState.pending]: { text: 'MCP server action?', icon: Server },
[ClientToolCallState.executing]: { text: 'MCP server action', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Updated MCP server', icon: Check },
[ClientToolCallState.error]: { text: 'Failed MCP server action', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted managing MCP tool',
text: 'Aborted MCP server action',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped managing MCP tool',
text: 'Skipped MCP server action',
icon: XCircle,
},
},
@@ -1079,7 +1177,7 @@ const META_manage_mcp_tool: ToolMetadata = {
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | undefined
const operation = params?.operation as ManageMcpToolOperation | undefined
if (!operation) return undefined
@@ -1087,23 +1185,31 @@ const META_manage_mcp_tool: ToolMetadata = {
const getActionText = (verb: 'present' | 'past' | 'gerund') => {
switch (operation) {
case 'add':
case ManageMcpToolOperation.add:
return verb === 'present' ? 'Add' : verb === 'past' ? 'Added' : 'Adding'
case 'edit':
case ManageMcpToolOperation.edit:
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete':
case ManageMcpToolOperation.delete:
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
case ManageMcpToolOperation.list:
return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing'
}
}
const shouldShowServerName = (currentState: ClientToolCallState) => {
if (operation === 'add') {
if (operation === ManageMcpToolOperation.list) return false
if (operation === ManageMcpToolOperation.add) {
return currentState === ClientToolCallState.success
}
return true
}
const nameText = shouldShowServerName(state) && serverName ? ` ${serverName}` : ' MCP tool'
const nameText =
operation === ManageMcpToolOperation.list
? ' MCP servers'
: shouldShowServerName(state) && serverName
? ` ${serverName}`
: ' MCP server'
switch (state) {
case ClientToolCallState.success:
@@ -2229,8 +2335,10 @@ const TOOL_METADATA_BY_ID: Record<string, ToolMetadata> = {
auth: META_auth,
context_compaction: META_context_compaction,
check_deployment_status: META_check_deployment_status,
complete_job: META_complete_job,
checkoff_todo: META_checkoff_todo,
crawl_website: META_crawl_website,
create_file: META_create_file,
create_workspace_mcp_server: META_create_workspace_mcp_server,
workflow: META_workflow,
create_folder: META_create_folder,

View File

@@ -4,10 +4,7 @@ import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SCRIPT_DIR, '..')
const DEFAULT_CATALOG_PATH = resolve(
ROOT,
'../copilot/copilot/contracts/tool-catalog-v1.json'
)
const DEFAULT_CATALOG_PATH = resolve(ROOT, '../copilot/copilot/contracts/tool-catalog-v1.json')
const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/tool-catalog-v1.ts')
const RUNTIME_SCHEMA_OUTPUT_PATH = resolve(
ROOT,
@@ -15,14 +12,56 @@ const RUNTIME_SCHEMA_OUTPUT_PATH = resolve(
)
function snakeToPascal(s: string): string {
return s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('')
return s
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('')
}
function toCamelIdentifier(value: string): string {
const parts = value.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length === 0) return 'value'
const camel = parts
.map((part, index) => {
const lower = part.toLowerCase()
if (index === 0) return lower
return lower.charAt(0).toUpperCase() + lower.slice(1)
})
.join('')
return /^[0-9]/.test(camel) ? `v${camel}` : camel
}
function getTopLevelOperationEnum(tool: Record<string, unknown>): string[] | undefined {
const parameters =
typeof tool.parameters === 'object' && tool.parameters !== null
? (tool.parameters as Record<string, unknown>)
: null
const properties =
parameters && typeof parameters.properties === 'object' && parameters.properties !== null
? (parameters.properties as Record<string, unknown>)
: null
const operation =
properties && typeof properties.operation === 'object' && properties.operation !== null
? (properties.operation as Record<string, unknown>)
: null
const values = operation?.enum
if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) {
return undefined
}
return values as string[]
}
function inferTSType(values: unknown[]): string {
const unique = [...new Set(values.filter((v) => v !== undefined && v !== null))]
if (unique.length === 0) return 'string'
if (unique.every((v) => typeof v === 'string')) {
return unique.map((v) => JSON.stringify(v)).sort().join(' | ')
return unique
.map((v) => JSON.stringify(v))
.sort()
.join(' | ')
}
if (unique.every((v) => typeof v === 'boolean')) return 'boolean'
if (unique.every((v) => typeof v === 'number')) return 'number'
@@ -47,7 +86,8 @@ function renderRuntimeSchemaModule(catalog: { tools: Record<string, unknown>[] }
for (const tool of catalog.tools) {
const id = JSON.stringify(tool.id)
const parameters = 'parameters' in tool ? JSON.stringify(tool.parameters ?? null, null, 2) : 'undefined'
const parameters =
'parameters' in tool ? JSON.stringify(tool.parameters ?? null, null, 2) : 'undefined'
const resultSchema =
'resultSchema' in tool ? JSON.stringify(tool.resultSchema ?? null, null, 2) : 'undefined'
lines.push(` [${id}]: {`)
@@ -96,7 +136,9 @@ function generateInterface(tools: Record<string, unknown>[]): string {
async function main() {
const checkOnly = process.argv.includes('--check')
const inputPathArg = process.argv.find((arg) => arg.startsWith('--input='))
const inputPath = inputPathArg ? resolve(ROOT, inputPathArg.slice('--input='.length)) : DEFAULT_CATALOG_PATH
const inputPath = inputPathArg
? resolve(ROOT, inputPathArg.slice('--input='.length))
: DEFAULT_CATALOG_PATH
const raw = await readFile(inputPath, 'utf8')
const catalog = JSON.parse(raw) as { version: string; tools: Record<string, unknown>[] }
@@ -122,11 +164,43 @@ async function main() {
fields.push(` ${key}: ${JSON.stringify(value)}`)
}
lines.push(`export const ${constName}: ToolCatalogEntry = {`)
lines.push(fields.join(',\n') + ',')
lines.push(`${fields.join(',\n')},`)
lines.push('};')
lines.push('')
}
for (const tool of catalog.tools) {
const constName = snakeToPascal(tool.id as string)
const operationEnum = getTopLevelOperationEnum(tool)
if (!operationEnum || operationEnum.length === 0) continue
const operationConstName = `${constName}Operation`
const seenKeys = new Set<string>()
const members = operationEnum.map((value, index) => {
let key = toCamelIdentifier(value)
if (seenKeys.has(key)) key = `${key}${index + 1}`
seenKeys.add(key)
return { key, value }
})
lines.push(`export const ${operationConstName} = {`)
for (const member of members) {
lines.push(` ${member.key}: ${JSON.stringify(member.value)},`)
}
lines.push('} as const;')
lines.push('')
lines.push(
`export type ${operationConstName} = (typeof ${operationConstName})[keyof typeof ${operationConstName}];`
)
lines.push('')
lines.push(`export const ${operationConstName}Values = [`)
for (const member of members) {
lines.push(` ${operationConstName}.${member.key},`)
}
lines.push(`] as const;`)
lines.push('')
}
lines.push(`export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {`)
for (let i = 0; i < catalog.tools.length; i++) {
lines.push(` [${constNames[i]}.id]: ${constNames[i]},`)
@@ -141,9 +215,7 @@ async function main() {
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
const existingRuntime = await readFile(RUNTIME_SCHEMA_OUTPUT_PATH, 'utf8').catch(() => null)
if (existing !== rendered || existingRuntime !== runtimeSchemaRendered) {
throw new Error(
`Generated tool catalog is stale. Run: bun run mship-tools:generate`
)
throw new Error(`Generated tool catalog is stale. Run: bun run mship-tools:generate`)
}
return
}