Standardize tool environment references

This commit is contained in:
Siddharth Ganesan
2026-08-14 20:16:52 -07:00
parent 24b24e5f8e
commit 3a9fc1c5a5
43 changed files with 464 additions and 100 deletions
+1 -1
View File
@@ -851,7 +851,7 @@ export class TerminalSession {
(pending) => ({
command: pending.command,
output:
'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. The user can also drive it in the panel. terminal_run reports BUSY until it exits.',
'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. If it is a pager (less, git log, man — the screen ends with ":" or "(END)"), nothing more is coming: exit it by sending terminal_input text "q"; terminal_kill delivers Ctrl-C, which a pager ignores. The user can also drive it in the panel. terminal_run reports BUSY until it exits.',
status: 'interactive',
exitCode: null,
durationMs: Date.now() - pending.startedAt,
@@ -18,8 +18,16 @@ vi.mock('@/lib/copilot/environment-context', () => ({
prepareCopilotEnvironmentContext: mockPrepareEnvironmentContext,
}))
vi.mock('@/lib/copilot/tools/registry/server-tool-adapter', () => ({
createServerToolHandler: () => mockHandler,
vi.mock('@/lib/copilot/tool-executor', () => ({
ensureHandlersRegistered: vi.fn(),
}))
vi.mock('@/lib/copilot/tool-executor/executor', () => ({
executeTool: (
_toolName: string,
params: Record<string, unknown>,
context: Record<string, unknown>
) => mockHandler(params, context),
}))
vi.mock('@/lib/copilot/request/tools/resources', () => ({
@@ -591,9 +591,9 @@ describe('completed tool titles', () => {
expect(failures).toEqual([])
})
it('keeps present tense while executing and on error', () => {
it('keeps present tense while executing; failed rows say so', () => {
expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs')
expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs')
expect(firstToolTitle([queryLogsCall('error')])).toBe('Failed querying logs')
})
})
+8 -2
View File
@@ -212,7 +212,10 @@ export const airtableConnector: ConnectorConfig = {
if (response.status === 403) {
return { valid: false, error: 'Access denied. Check your Airtable permissions.' }
}
return { valid: false, error: `Airtable API error: ${response.status} - ${errorText}` }
return {
valid: false,
error: `Airtable API error: ${response.status} — 401 means an invalid PAT (or an unresolved {{ENV_VAR}} placeholder); 403 means the PAT has no access to base "${baseId}". Detail: ${errorText}`,
}
}
const viewId = sourceConfig.viewId as string | undefined
@@ -229,7 +232,10 @@ export const airtableConnector: ConnectorConfig = {
VALIDATE_RETRY_OPTIONS
)
if (!viewResponse.ok) {
return { valid: false, error: `View "${viewId}" not found in table "${tableIdOrName}"` }
return {
valid: false,
error: `View "${viewId}" not found in table "${tableIdOrName}" — or the PAT lacks access to it.`,
}
}
}
+5 -2
View File
@@ -493,7 +493,10 @@ export const confluenceConnector: ConnectorConfig = {
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Failed to validate spaces: ${response.status}` }
return {
valid: false,
error: `Failed to list Confluence spaces: ${response.status} — 401/403 means the credential lacks space-read scope on this site; 404 means the domain is wrong.`,
}
}
const data = await response.json()
const results = (data.results as Array<Record<string, unknown>> | undefined) ?? []
@@ -502,7 +505,7 @@ export const confluenceConnector: ConnectorConfig = {
if (missing.length > 0) {
return {
valid: false,
error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`,
error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')} — the credential may not see them; they may be in another Atlassian site or restricted spaces the connected user is not a member of.`,
}
}
return { valid: true }
+9 -2
View File
@@ -272,10 +272,17 @@ export const discordConnector: ConnectorConfig = {
} catch (error) {
const message = getErrorMessage(error, 'Failed to validate configuration')
if (message.includes('401') || message.includes('403')) {
return { valid: false, error: 'Invalid bot token or missing permissions for this channel' }
return {
valid: false,
error:
'Discord rejected the request (401/403) — the bot token is invalid, or the bot lacks access to this channel (invite the bot to the server/channel and grant Read Message History).',
}
}
if (message.includes('404')) {
return { valid: false, error: `Channel not found: ${channelId}` }
return {
valid: false,
error: `Channel not found: ${channelId}. The bot cannot see it — invite the bot to that server/channel, or check the channel id.`,
}
}
return { valid: false, error: message }
}
+12 -2
View File
@@ -968,8 +968,18 @@ export const gitlabConnector: ConnectorConfig = {
if (response.status === 404) {
return { valid: false, error: `Project "${project}" not found on ${host}` }
}
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid token or insufficient permissions' }
if (response.status === 401) {
return {
valid: false,
error:
'GitLab rejected the token (401) — it is invalid, expired, or an unresolved {{ENV_VAR}} placeholder. Pass a valid token or a {{ENV_VAR}} reference to one.',
}
}
if (response.status === 403) {
return {
valid: false,
error: `GitLab token lacks access (403) — it needs read_api/read_repository on "${project}".`,
}
}
if (!response.ok) {
return { valid: false, error: `Cannot access project: ${response.status}` }
@@ -358,7 +358,7 @@ export const googleDriveConnector: ConnectorConfig = {
}
return {
valid: false,
error: `Failed to access folder "${folderId}": ${response.status}`,
error: `Failed to access folder "${folderId}": ${response.status} — 403 means the folder exists but is not shared with the connected Google account.`,
}
}
@@ -383,7 +383,10 @@ export const googleDriveConnector: ConnectorConfig = {
)
if (!response.ok) {
return { valid: false, error: `Failed to access Google Drive: ${response.status}` }
return {
valid: false,
error: `Failed to access Google Drive: ${response.status} — 401 means the token expired (reconnect the Google credential); 403 usually means a missing Drive scope.`,
}
}
}
@@ -348,7 +348,10 @@ export const microsoftTeamsConnector: ConnectorConfig = {
for (const channelInput of channelInputs) {
const channel = await resolveChannel(accessToken, teamId, channelInput)
if (!channel) {
return { valid: false, error: `Channel not found: ${channelInput}` }
return {
valid: false,
error: `Channel not found: ${channelInput}. The connected account cannot see it — it may be in a different team/tenant, or a private channel the user is not a member of.`,
}
}
// Verify we can read messages by fetching a single message
+5 -2
View File
@@ -282,7 +282,7 @@ export const notionConnector: ConnectorConfig = {
if (!response.ok) {
return {
valid: false,
error: `Cannot access database ${databaseId}: ${response.status}`,
error: `Cannot access database ${databaseId}: ${response.status} — 401 means a rejected token; 404 usually means the database is not shared with this Notion integration (share it from the page's "Connections" menu).`,
}
}
}
@@ -300,7 +300,10 @@ export const notionConnector: ConnectorConfig = {
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Cannot access page: ${response.status}` }
return {
valid: false,
error: `Cannot access page ${rootPageId}: ${response.status} — the page is likely not shared with this Notion integration; add it under the page's "Connections" menu.`,
}
}
} else {
// Workspace scope — just verify token works
@@ -3482,7 +3482,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = {
},
minSize: {
type: 'number',
description: 'Minimum chunk size (1-2000, default: 1)',
description: 'Minimum chunk size (1-2000, default: 100)',
default: 1,
},
overlap: {
@@ -3604,7 +3604,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = {
},
topK: {
type: 'number',
description: 'Number of results to return (1-50, default: 5)',
description: 'Number of results to return (1-100, default: 5)',
default: 5,
},
workspaceId: {
@@ -3672,7 +3672,8 @@ export const ManageMcpConnection: ToolCatalogEntry = {
},
headers: {
type: 'object',
description: 'Optional HTTP headers to send with requests (key-value pairs)',
description:
'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.',
},
name: { type: 'string', description: 'Display name for the MCP server' },
timeout: {
@@ -4371,7 +4372,7 @@ export const QueryUserTable: ToolCatalogEntry = {
limit: {
type: 'number',
description:
'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).',
'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.',
},
order: {
type: 'array',
@@ -4548,7 +4549,16 @@ export const RestoreResource: ToolCatalogEntry = {
type: {
type: 'string',
description: 'The resource type to restore.',
enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'],
enum: [
'workflow',
'table',
'file',
'knowledgebase',
'folder',
'file_folder',
'table_folder',
'knowledge_folder',
],
},
},
required: ['type', 'id'],
@@ -3405,7 +3405,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
minSize: {
type: 'number',
description: 'Minimum chunk size (1-2000, default: 1)',
description: 'Minimum chunk size (1-2000, default: 100)',
default: 1,
},
overlap: {
@@ -3539,7 +3539,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
topK: {
type: 'number',
description: 'Number of results to return (1-50, default: 5)',
description: 'Number of results to return (1-100, default: 5)',
default: 5,
},
workspaceId: {
@@ -3608,7 +3608,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
headers: {
type: 'object',
description: 'Optional HTTP headers to send with requests (key-value pairs)',
description:
'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.',
},
name: {
type: 'string',
@@ -4300,7 +4301,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
limit: {
type: 'number',
description:
'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).',
'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.',
},
order: {
type: 'array',
@@ -4497,7 +4498,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
type: {
type: 'string',
description: 'The resource type to restore.',
enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'],
enum: [
'workflow',
'table',
'file',
'knowledgebase',
'folder',
'file_folder',
'table_folder',
'knowledge_folder',
],
},
},
required: ['type', 'id'],
@@ -303,6 +303,10 @@ export async function executeDeployCustomBlock(
return { success: false, error: error.message }
}
logger.error('Custom block deployment failed', { error })
return { success: false, error: 'Custom block deployment failed due to a system error' }
return {
success: false,
error:
'Publishing the custom block failed inside Sim; assume it was NOT published. Call get_deployment_status to confirm, retry once, and report the failure if it repeats instead of retrying further.',
}
}
}
@@ -4,6 +4,7 @@ import {
messageForCopilotWorkflowError,
} from '@/lib/copilot/application/execute-workflow-use-case'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
@@ -357,6 +358,19 @@ export async function executeDeployChat(
}
}
// "Use the password in {{CHAT_PW}}" arrives as the literal reference —
// resolve it, or the placeholder string becomes the chat's real password.
const resolvedPassword = await resolveEnvReferenceSecretArg({
userId: context.userId,
workspaceId: context.workspaceId,
value: params.password ?? undefined,
argName: 'password',
registry: context.resolvedSecretTraceRegistry,
})
if (resolvedPassword.error) {
return { success: false, error: resolvedPassword.error }
}
const result = await executeCopilotWorkflowUseCase(context, deployWorkflowChat, {
workflowId,
assertedWorkspaceId: context.workspaceId,
@@ -371,7 +385,7 @@ export async function executeDeployChat(
imageUrl: params.customizations?.imageUrl ?? params.customizations?.iconUrl,
},
authType: params.authType,
password: params.password,
password: resolvedPassword.value,
allowedEmails: params.allowedEmails,
outputConfigs: params.outputConfigs,
includeThinking: params.includeThinking,
@@ -636,6 +636,16 @@ export async function executeFunctionExecute(
'internalSandboxProfile',
PRIVATE_SECRET_PROVENANCE_FIELD,
])
// The copilot tool doc promises `timeout` in SECONDS ("Sim converts to
// milliseconds", default 10, cap 300); the underlying function tool takes
// MILLISECONDS. Nothing converted, so `timeout: 120` armed a 120ms abort.
// Values ≤ 600 are read as seconds; larger values are assumed to already be
// milliseconds (a model habit worth tolerating). Both clamp to the 300s cap.
if (typeof enrichedParams.timeout === 'number' && Number.isFinite(enrichedParams.timeout)) {
const raw = enrichedParams.timeout
const ms = raw <= 600 ? raw * 1000 : raw
enrichedParams.timeout = Math.min(Math.max(ms, 1000), 300_000)
}
if (params.sandboxId !== undefined) {
if (typeof params.sandboxId !== 'string' || !params.sandboxId.trim()) {
throw new Error('sandboxId must be a non-empty Sim sandbox id')
@@ -250,7 +250,7 @@ export async function executeManageCustomTool(
error:
classified && classified.code !== 'internal'
? classified.message
: 'Failed to manage custom tool',
: `The ${operation ?? 'custom tool'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`,
}
}
}
@@ -205,7 +205,7 @@ export async function executeManageMcpTool(
error:
classified && classified.code !== 'internal'
? classified.message
: 'Failed to manage MCP server',
: `The ${operation ?? 'MCP server'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`,
}
}
}
@@ -102,7 +102,11 @@ export async function executeManageSandbox(
workspaceId,
SANDBOX_MUTATION_LIMIT
)
if (limited) return { success: false, error: 'Rate limit exceeded' }
if (limited)
return {
success: false,
error: `Rate limit exceeded for sandbox ${operation} in this workspace — do not retry now; continue with other work or tell the user the limit was hit.`,
}
if (operation === 'add') {
const parsed = createSandboxBodySchema.safeParse({
@@ -66,7 +66,9 @@ async function resolveResource(
const wf = await getWorkflowById(item.id)
if (!wf) return { error: `No workflow with id "${item.id}".` }
if (context.workspaceId && wf.workspaceId !== context.workspaceId)
return { error: `Workflow not found in the current workspace.` }
return {
error: `Workflow "${item.id}" is not in the current workspace — run glob("workflows/*/meta.json") for workflows you can reference.`,
}
resourceId = wf.id
title = wf.name
}
@@ -75,7 +77,9 @@ async function resolveResource(
const tbl = await getTableById(item.id)
if (!tbl) return { error: `No table with id "${item.id}".` }
if (context.workspaceId && tbl.workspaceId !== context.workspaceId)
return { error: `Table not found in the current workspace.` }
return {
error: `Table "${item.id}" is not in the current workspace — run glob("tables/*") for tables you can reference.`,
}
resourceId = tbl.id
title = tbl.name
if (item.view) {
@@ -113,7 +117,9 @@ async function resolveResource(
classified?.code === 'forbidden' ||
classified?.code === 'unauthorized'
) {
return { error: 'Knowledge base not found in the current workspace.' }
return {
error: `Knowledge base "${item.id}" is not readable in the current workspace — it does not exist here or you lack access. Run glob("knowledgebases/*") for ids you can open.`,
}
}
throw error
}
@@ -125,7 +131,9 @@ async function resolveResource(
const logRecord = await getLogById(item.id)
if (!logRecord) return { error: `No log with id "${item.id}".` }
if (context.workspaceId && logRecord.workspaceId !== context.workspaceId)
return { error: `Log not found in the current workspace.` }
return {
error: `Log "${item.id}" is not in the current workspace — use query_logs to find valid execution ids.`,
}
resourceId = logRecord.id
const workflowName = logRecord.workflowName ?? 'Unknown Workflow'
const timestamp = logRecord.startedAt.toLocaleString('en-US', {
@@ -290,7 +290,11 @@ export async function executeVfsMkdir(
if (top === 'tables' || top === 'knowledgebases') {
outcomes.push(
folderedOutcomes.get(path) ?? { from: path, kind, error: 'Folder creation failed' }
folderedOutcomes.get(path) ?? {
from: path,
kind,
error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`,
}
)
continue
}
@@ -312,7 +316,7 @@ export async function executeVfsMkdir(
fileOutcomes.get(path) ?? {
from: path,
kind: 'file_folder',
error: 'File folder creation failed',
error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`,
}
)
} else {
@@ -320,7 +324,7 @@ export async function executeVfsMkdir(
workflowOutcomes.get(path) ?? {
from: path,
kind: 'workflow_folder',
error: 'Workflow folder creation failed',
error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`,
}
)
}
@@ -646,12 +650,16 @@ export async function executeVfsRm(
workflowOutcomes.get(path) ?? {
from: path,
kind: 'workflow',
error: 'Workflow deletion failed',
error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("workflows/*") to confirm before retrying.`,
}
)
} else if (classified.category === 'files') {
outcomes.push(
fileOutcomes.get(path) ?? { from: path, kind: 'file', error: 'File deletion failed' }
fileOutcomes.get(path) ?? {
from: path,
kind: 'file',
error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("files/**") to confirm before retrying.`,
}
)
} else {
outcomes.push(await removeOne(classified.category, path, context, workspaceId))
@@ -0,0 +1,39 @@
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
/**
* Resolves a whole-value `{{ENV_VAR}}` reference in a secret-bearing tool arg.
*
* Copilot agents never see secret values — the workspace exposes variable
* NAMES only — so when a user says "use the password in CHAT_PW" the model
* passes `{{CHAT_PW}}`. Without resolution the literal seven-character
* placeholder becomes the stored secret and nothing ever errors. Only the
* explicit braced form resolves here: unlike API keys, passwords are
* free-form strings, so `$NAME`/bare-name heuristics would corrupt real ones.
*
* Returns an error when the referenced variable is unset so the model learns
* the actual fix instead of silently storing the placeholder.
*/
export async function resolveEnvReferenceSecretArg(args: {
userId: string
workspaceId?: string
value: string | undefined
argName: string
registry?: ResolvedSecretTraceRegistry
}): Promise<{ value?: string; error?: string }> {
const { value } = args
if (!value) return { value }
const braced = value.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/)
if (!braced) return { value }
const name = braced[1]
const env = await getEffectiveDecryptedEnv(args.userId, args.workspaceId)
const resolved = env[name]
if (resolved === undefined || resolved === '') {
return {
error: `Environment variable "${name}" referenced by ${args.argName} is not set for this workspace or user. Set it first, or pass the raw value.`,
}
}
// Activate on the call's egress registry so an accidental echo is redacted.
args.registry?.recordResolved(name, resolved)
return { value: resolved }
}
@@ -166,9 +166,17 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
})
if (!response.ok) {
const hint =
response.status === 401 || response.status === 403
? ' — the URL requires authentication this tool cannot supply; ask for a public or pre-signed link instead'
: response.status === 404
? ' — the URL does not exist; verify it before retrying'
: response.status === 429
? ' — the host is rate-limiting; do not retry immediately'
: ' — the host rejected the request; retrying the same URL will fail again'
return {
success: false,
message: `Download failed with status ${response.status} ${response.statusText}`,
message: `Download failed with status ${response.status} ${response.statusText}${hint}`,
}
}
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case'
import {
messageForCopilotFileError,
@@ -114,7 +115,19 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
if (firstIdx === -1) {
return {
success: false,
message: `Patch failed: search string not found in file "${fileRecord.name}"`,
message: `Patch failed: search string not found in file "${fileRecord.name}": ${JSON.stringify(truncate(search, 120))}`,
}
}
// The tool doc promises "must match exactly once unless
// replaceAll" — enforce it, or an ambiguous search silently
// rewrites the first occurrence with a success receipt.
if (!intent.edit.replaceAll) {
const occurrences = existing.split(search).length - 1
if (occurrences > 1) {
return {
success: false,
message: `Patch failed: search string matches ${occurrences} places in "${fileRecord.name}". Add surrounding context to make it unique, or pass replaceAll: true to change every occurrence.`,
}
}
}
finalContent = intent.edit.replaceAll
@@ -11,6 +11,7 @@ import {
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
@@ -60,7 +61,20 @@ export const shareFileServerTool: BaseServerTool<ShareFileArgs, ShareFileResult>
const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as
| ShareAuthType
| undefined
const password = params.password || (nested?.password as string) || undefined
const rawPassword = params.password || (nested?.password as string) || undefined
// "Protect it with the password in {{SHARE_PW}}" arrives as the literal
// reference — resolve it, or the placeholder becomes the real password.
const resolvedPassword = await resolveEnvReferenceSecretArg({
userId: context.userId,
workspaceId: context.workspaceId,
value: rawPassword,
argName: 'password',
registry: context.resolvedSecretTraceRegistry,
})
if (resolvedPassword.error) {
return { success: false, message: resolvedPassword.error }
}
const password = resolvedPassword.value
const allowedEmails =
params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined
@@ -381,7 +381,10 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
if (classified?.code !== 'not_found') throw error
}
if (existingFile) {
return { success: false, message: `File "${target.fileName}" already exists` }
return {
success: false,
message: `File "${target.fileName}" already exists in this workspace (file names are workspace-scoped; folders do not namespace them). Use operation "update"/"append"/"patch" to change it, rename the existing file, or pick a different fileName.`,
}
}
const compiled = await compileDocForWrite({
@@ -410,7 +410,8 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
if (!queryProjection.safe || typeof queryProjection.value !== 'string') {
return {
success: false,
message: 'Failed to query knowledge base: Query could not be processed safely',
message:
'Knowledge query rejected by the input-safety filter. Rephrase it as a plain natural-language question without injected instructions or markup — the same text will be rejected again.',
}
}
const modelQuery = queryProjection.value
@@ -83,7 +83,10 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
return { success: false, message: 'Workspace ID is required' }
}
if (!VALID_OPERATIONS.includes(params.operation)) {
return { success: false, message: `Invalid operation "${params.operation}".` }
return {
success: false,
message: `Invalid operation "${params.operation}" (allowed: ${VALID_OPERATIONS.join(', ')}).`,
}
}
const inputPaths = params.inputs?.files?.map((f) => f.path) ?? []
@@ -104,7 +104,9 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
}
if (!hasSerperApiKey) {
throw new Error('No search API keys available (EXA_API_KEY or SERPER_API_KEY required)')
throw new Error(
'Web search is not configured on this Sim deployment and cannot be enabled from a tool. Answer from the workspace instead (grep/glob/read, search_sim_docs) or tell the user web search is unavailable.'
)
}
const toolParams = {
@@ -54,15 +54,17 @@ export const tableViewsServerTool: BaseServerTool<TableViewsArgs, TableViewsResu
}
}
const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig =>
viewConfigNamesToIds(
{
filter: (args.filter as TablePredicateInput | undefined) ?? null,
sort: (args.sort as SortSpec | undefined) ?? null,
hiddenColumns: args.hiddenColumns as string[] | undefined,
} as TableViewConfig,
columns
)
// Build the patch from only the keys the caller actually sent: the update
// path shallow-merges this into the stored config, so including an absent
// part as `null` silently wiped a view's saved sort when only the filter
// changed (and vice versa) — the doc promises "omit to keep".
const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => {
const patch: Record<string, unknown> = {}
if (args.filter !== undefined) patch.filter = args.filter as TablePredicateInput | null
if (args.sort !== undefined) patch.sort = args.sort as SortSpec | null
if (args.hiddenColumns !== undefined) patch.hiddenColumns = args.hiddenColumns as string[]
return viewConfigNamesToIds(patch as TableViewConfig, columns)
}
switch (operation) {
case 'list_views': {
@@ -1124,7 +1124,7 @@ export async function validateWorkflowSelectorIds(
blockType: selector.blockType,
field: selector.fieldName,
value: selector.value,
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`,
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} — they do not exist in this workspace or you lack access. Discover valid ids first (glob/read the matching workspace resource, e.g. environment/credentials.json, knowledgebases/*/meta.json, tables/*/meta.json) instead of guessing${warningInfo}`,
})
} else if (result.warning) {
// Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors)
@@ -1733,7 +1733,7 @@ export async function preValidateCredentialInputs(
blockType: credInput.blockType,
field: credInput.fieldName,
value: credInput.value,
error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`,
error: `Invalid credential ID "${credInput.value}" for ${credInput.blockType}.${credInput.fieldName} — the field was removed from the block. Read environment/credentials.json for connected credential ids, or use oauth_get_auth_link (via the auth agent) to connect the provider first; never invent credential ids${warningInfo}`,
})
}
@@ -179,12 +179,26 @@ describe('getToolCompletedTitle', () => {
expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined()
})
it('projects completed titles only for successful rows', () => {
it('projects a terminal tense for every settled row, present tense only while running', () => {
expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows')
expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe(
'Comparing workflows'
)
expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows')
// An errored row must not read as still running — the frozen present-tense
// title ("Searching for X" forever) was reported as a stuck tool call.
expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe(
'Failed comparing workflows'
)
expect(getToolStatusDisplayTitle('Searching for admin mentions', 'error')).toBe(
'Failed searching for admin mentions'
)
expect(getToolStatusDisplayTitle('Comparing workflows', 'cancelled')).toBe(
'Stopped comparing workflows'
)
// Non-gerund titles get a prefix rather than a bad rewrite.
expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe(
'Failed: Read recent emails'
)
})
})
+31 -3
View File
@@ -1136,11 +1136,36 @@ export function getToolCompletedTitle(title: string): string | undefined {
return past + title.slice(firstWord.length)
}
/**
* Rewrite a resolved display title for a FAILED tool call. A gerund title
* becomes "Failed <gerund>…" ("Searching for X" "Failed searching for X");
* anything else gets a "Failed: " prefix. Without this, an errored row kept
* its present-tense activity title verbatim and read as still running.
*/
export function getToolFailedTitle(title: string): string {
const spaceIndex = title.indexOf(' ')
const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex)
if (COMPLETED_VERB_REWRITES[firstWord]) {
return `Failed ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}`
}
return `Failed: ${title}`
}
/** Rewrite a resolved display title for a CANCELLED tool call ("Stopped <gerund>…"). */
export function getToolStoppedTitle(title: string): string {
const spaceIndex = title.indexOf(' ')
const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex)
if (COMPLETED_VERB_REWRITES[firstWord]) {
return `Stopped ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}`
}
return `Stopped: ${title}`
}
/**
* Resolve the final title for a tool status at a rendering boundary. Persisted
* and live snapshots intentionally keep the present-tense activity title so a
* running/error row remains truthful; every successful renderer calls this to
* project the corresponding completed title from the canonical verb map.
* RUNNING row remains truthful; terminal states project a tense that says the
* work is over completed (past tense), failed, or stopped.
*/
export function getToolStatusDisplayTitle(
title: string,
@@ -1150,5 +1175,8 @@ export function getToolStatusDisplayTitle(
if (status === 'success' && toolName === 'browser_request_takeover') {
return 'Resumed browser control'
}
return status === 'success' ? (getToolCompletedTitle(title) ?? title) : title
if (status === 'success') return getToolCompletedTitle(title) ?? title
if (status === 'error' || status === 'rejected') return getToolFailedTitle(title)
if (status === 'cancelled' || status === 'aborted') return getToolStoppedTitle(title)
return title
}
+17
View File
@@ -224,6 +224,8 @@ export function serializeRecentExecutions(
* but never filters anything.
*/
export interface KbTagDefinitionSummary {
/** The tagDefinitionId that update_tag / delete_tag / update_document.tagValues require. */
id: string
tagName: string
tagSlot: string
fieldType: string
@@ -872,12 +874,17 @@ export interface DeploymentData {
authType: string
customizations: unknown
isActive: boolean
allowedEmails?: unknown
outputConfigs?: unknown
includeThinking?: boolean | null
includeToolCalls?: boolean | null
} | null
mcp: Array<{
serverId: string
serverName: string
toolId: string
toolName: string
parameterDescriptionOverrides?: unknown
toolDescription?: string | null
}>
versions?: Array<{
@@ -911,6 +918,9 @@ export function serializeDeployments(data: DeploymentData): string {
: { isDeployed: false }
if (data.chat) {
// allowedEmails/outputConfigs/includeThinking/includeToolCalls are the
// fields deploy_as_chat accepts on redeploy; exposing the current values is
// what lets a caller change one setting without blanking the others.
result.chat = {
id: data.chat.id,
identifier: data.chat.identifier,
@@ -920,6 +930,10 @@ export function serializeDeployments(data: DeploymentData): string {
authType: data.chat.authType,
customizations: data.chat.customizations,
isActive: data.chat.isActive,
allowedEmails: data.chat.allowedEmails ?? undefined,
outputConfigs: data.chat.outputConfigs ?? undefined,
includeThinking: data.chat.includeThinking ?? undefined,
includeToolCalls: data.chat.includeToolCalls ?? undefined,
}
}
@@ -930,6 +944,9 @@ export function serializeDeployments(data: DeploymentData): string {
toolId: m.toolId,
toolName: m.toolName,
toolDescription: m.toolDescription || undefined,
// What deploy_as_mcp accepts as `parameters` on redeploy; omitting it
// there resets the overrides, so expose the current value.
parameterDescriptionOverrides: m.parameterDescriptionOverrides ?? undefined,
}))
}
@@ -1925,6 +1925,7 @@ export class WorkspaceVFS {
documentCount: kb.docCount,
connectorTypes: kb.connectorTypes,
tagDefinitions: tagDefinitions.map((definition) => ({
id: definition.id,
tagName: definition.displayName,
tagSlot: definition.tagSlot,
fieldType: definition.fieldType,
@@ -2152,6 +2153,10 @@ export class WorkspaceVFS {
authType: chatTable.authType,
customizations: chatTable.customizations,
isActive: chatTable.isActive,
allowedEmails: chatTable.allowedEmails,
outputConfigs: chatTable.outputConfigs,
includeThinking: chatTable.includeThinking,
includeToolCalls: chatTable.includeToolCalls,
})
.from(chatTable)
.where(and(eq(chatTable.workflowId, workflowId), isNull(chatTable.archivedAt))),
@@ -2162,6 +2167,7 @@ export class WorkspaceVFS {
toolId: workflowMcpTool.id,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
})
.from(workflowMcpTool)
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
@@ -95,6 +95,7 @@ export interface ListArchivedKnowledgeBasesResult {
}
export interface KnowledgeBaseCatalogTagDefinition {
id: string
knowledgeBaseId: string
tagSlot: string
displayName: string
@@ -386,6 +387,7 @@ export const listKnowledgeBaseCatalog = defineAuthorizedKnowledgeUseCase({
? []
: await db
.select({
id: knowledgeBaseTagDefinitions.id,
knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId,
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
displayName: knowledgeBaseTagDefinitions.displayName,
@@ -178,7 +178,11 @@ export async function performCreateKnowledgeConnector(
const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig)
if (!configValidation.valid) {
return fail(configValidation.error || 'Invalid source configuration', 'validation')
return fail(
configValidation.error ||
`The ${connectorType} connector rejected sourceConfig without a reason — re-check its required fields in knowledgebases/connectors/${connectorType}.json before retrying; the same config will fail again.`,
'validation'
)
}
if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
@@ -47,7 +47,10 @@ describe('table application context', () => {
it('conceals an asserted cross-workspace table before workspace resolution', async () => {
await expect(
resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' })
).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' })
).rejects.toMatchObject({
code: 'not_found',
message: expect.stringContaining('not found in this workspace'),
})
expect(loadWorkspace).not.toHaveBeenCalled()
})
+6 -1
View File
@@ -27,7 +27,12 @@ export async function resolveActiveTableContext(input: {
!table ||
(input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId)
) {
throw new OrchestrationError('not_found', 'Table not found')
// One message for "no such table" and "table in another workspace" so
// existence never leaks across workspaces — but actionable either way.
throw new OrchestrationError(
'not_found',
`Table "${input.tableId}" not found in this workspace — it may not exist or may belong to a different workspace. Run glob("tables/*") to list the tables you can use here.`
)
}
const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId)
return { ...workspaceContext, tableId: table.id, table }
+9 -2
View File
@@ -224,7 +224,10 @@ export const updateTableUseCase = defineAuthorizedTableUseCase({
const table = await getTableById(current.id)
if (!table || table.workspaceId !== context.workspaceId) {
throw new OrchestrationError('not_found', 'Table not found')
throw new OrchestrationError(
'not_found',
'Table not found in this workspace — run glob("tables/*") to list valid tables'
)
}
const index =
resolution?.index ??
@@ -295,7 +298,11 @@ export const deleteTableUseCase = defineAuthorizedTableUseCase({
const { archived } = await deleteTable(context.table.id, generateRequestId(), {
expectedWorkspaceId: context.workspaceId,
})
if (!archived) throw new OrchestrationError('not_found', 'Table not found')
if (!archived)
throw new OrchestrationError(
'not_found',
'Table not found in this workspace — run glob("tables/*") to list valid tables'
)
return {
id: context.table.id,
deleted: true as const,
+25 -5
View File
@@ -61,7 +61,11 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({
(context.table.schema as TableSchema).columns,
context.workspaceId
)
if (!view) throw new OrchestrationError('not_found', 'View not found')
if (!view)
throw new OrchestrationError(
'not_found',
'View not found on this table — call table_views with operation "list_views" for valid view ids'
)
return { view, table: context.table }
},
})
@@ -130,7 +134,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({
(context.table.schema as TableSchema).columns,
context.workspaceId
)
if (!existing) throw new OrchestrationError('not_found', 'View not found')
if (!existing)
throw new OrchestrationError(
'not_found',
'View not found on this table — call table_views with operation "list_views" for valid view ids'
)
const view = await updateTableView({
viewId: input.viewId,
tableId: context.table.id,
@@ -141,7 +149,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({
isDefault: input.isDefault,
columns: (context.table.schema as TableSchema).columns,
})
if (!view) throw new OrchestrationError('not_found', 'View not found')
if (!view)
throw new OrchestrationError(
'not_found',
'View not found on this table — call table_views with operation "list_views" for valid view ids'
)
return {
view,
table: context.table,
@@ -181,9 +193,17 @@ export const deleteTableViewUseCase = defineAuthorizedTableUseCase({
(context.table.schema as TableSchema).columns,
context.workspaceId
)
if (!existing) throw new OrchestrationError('not_found', 'View not found')
if (!existing)
throw new OrchestrationError(
'not_found',
'View not found on this table — call table_views with operation "list_views" for valid view ids'
)
const deleted = await deleteTableView(input.viewId, context.table.id, context.workspaceId)
if (!deleted) throw new OrchestrationError('not_found', 'View not found')
if (!deleted)
throw new OrchestrationError(
'not_found',
'View not found on this table — call table_views with operation "list_views" for valid view ids'
)
return { viewId: input.viewId, viewName: existing.name, table: context.table }
},
projectAudit({ result }) {
@@ -65,19 +65,7 @@ export interface PerformChatDeployResult {
export async function performChatDeploy(
params: ChatDeployPayload
): Promise<PerformChatDeployResult> {
const {
workflowId,
userId,
identifier,
title,
description = '',
authType = 'public',
password,
allowedEmails = [],
outputConfigs = [],
includeThinking = false,
includeToolCalls = false,
} = params
const { workflowId, userId, identifier, title, password } = params
/**
* Validate the password here rather than only at the HTTP boundary. The
@@ -93,10 +81,60 @@ export async function performChatDeploy(
}
}
/**
* Redeploys merge: any field the caller omitted keeps the existing chat's
* value instead of being reset to a default. Before this, a copilot
* `deploy_as_chat` call that changed only the title silently flipped an
* email/sso-protected chat back to public, wiped its allowlist and output
* configuration, and reset the welcome customizations the caller had no
* way to know, because none of those fields were readable back. Defaults
* apply only when there is no existing deployment to preserve.
*/
const [existingDeployment] = await db
.select()
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1)
const authType =
params.authType ??
(existingDeployment?.authType as ChatDeployPayload['authType'] | undefined) ??
'public'
const description =
params.description !== undefined ? params.description : (existingDeployment?.description ?? '')
const allowedEmails =
params.allowedEmails ?? (existingDeployment?.allowedEmails as string[] | null) ?? []
const outputConfigs =
params.outputConfigs ??
(existingDeployment?.outputConfigs as Array<{ blockId: string; path: string }> | null) ??
[]
const includeThinking = params.includeThinking ?? existingDeployment?.includeThinking ?? false
const includeToolCalls = params.includeToolCalls ?? existingDeployment?.includeToolCalls ?? false
// Per-field merge (params over existing over defaults): callers routinely
// send a customizations object with only some fields set, and a hard default
// for the rest silently reset the chat's colors and welcome message.
const existingCustomizations =
existingDeployment?.customizations &&
typeof existingDeployment.customizations === 'object' &&
!Array.isArray(existingDeployment.customizations)
? (existingDeployment.customizations as {
primaryColor?: string
welcomeMessage?: string
imageUrl?: string
})
: undefined
const mergedImageUrl = params.customizations?.imageUrl || existingCustomizations?.imageUrl
const customizations = {
primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)',
welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}),
primaryColor:
params.customizations?.primaryColor ||
existingCustomizations?.primaryColor ||
'var(--brand-hover)',
welcomeMessage:
params.customizations?.welcomeMessage ||
existingCustomizations?.welcomeMessage ||
'Hi there! How can I help you today?',
...(mergedImageUrl ? { imageUrl: mergedImageUrl } : {}),
}
/**
@@ -162,12 +200,6 @@ export async function performChatDeploy(
encryptedPassword = encrypted
}
const [existingDeployment] = await db
.select()
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1)
/**
* A password-protected chat must end up with a stored password. Both HTTP
* routes already reject this; without the same guard here a copilot
+23 -3
View File
@@ -350,12 +350,29 @@ async function resolveCopilotEnvReferences(
return
}
const pending: Array<{ paramId: string; value: string }> = []
// Models improvise reference syntax: after `{{NAME}}`, `$NAME` and the bare
// variable name are the common fallbacks — both previously went upstream as
// the literal credential and failed with an undiagnosable 401. `{{NAME}}`
// and `$NAME` are unambiguous references (a real key never starts with `$`),
// so a missing variable is a hard error. A bare name is a reference only
// when a variable by that exact name exists (`soft`): plenty of real API
// keys match the identifier pattern, and those must pass through verbatim.
const pending: Array<{ paramId: string; value: string; soft?: boolean }> = []
for (const [paramId, paramDef] of Object.entries(tool.params || {})) {
if (paramDef?.visibility !== 'user-only') continue
const value = params[paramId]
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
if (typeof value !== 'string') continue
if (value.startsWith('{{') && value.endsWith('}}')) {
pending.push({ paramId, value })
continue
}
const dollar = value.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/)
if (dollar) {
pending.push({ paramId, value: `{{${dollar[1]}}}` })
continue
}
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
pending.push({ paramId, value: `{{${value}}}`, soft: true })
}
}
@@ -374,7 +391,7 @@ async function resolveCopilotEnvReferences(
const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils')
const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId)
for (const { paramId, value } of pending) {
for (const { paramId, value, soft } of pending) {
const missingKeys: string[] = []
const resolved = resolveEnvVarReferences(value, envVars, {
allowEmbedded: false,
@@ -386,6 +403,9 @@ async function resolveCopilotEnvReferences(
},
})
if (missingKeys.length > 0) {
// A bare name that matches no variable is treated as the literal
// credential it probably is; only explicit reference forms error.
if (soft) continue
const scopeHint = scope.workspaceId
? ''
: ' (no workspace context — only personal variables are available here)'
+10
View File
@@ -617,6 +617,16 @@ export function createUserToolSchema(
.filter(Boolean)
.join(' ')
}
// Copilot agents never see secret values, only names — so tell them the
// reference form works here, or they paste placeholders that fail upstream.
if (visibility === 'user-only' && surface === 'copilot') {
propertySchema.description = [
propertySchema.description,
'Accepts an environment-variable reference like {{VAR_NAME}} (see environment/variables.json), resolved server-side.',
]
.filter(Boolean)
.join(' ')
}
schema.properties[paramId] = propertySchema
if (param.required && paramId !== hostedApiKeyParam) {