chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
|
||||
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
||||
import {
|
||||
MothershipStreamV1CompletionStatus,
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1TextChannel,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { sseHandlers } from '@/lib/copilot/request/handlers'
|
||||
import type {
|
||||
ExecutionContext,
|
||||
OrchestratorOptions,
|
||||
StreamEvent,
|
||||
StreamingContext,
|
||||
} from '@/lib/copilot/request/types'
|
||||
|
||||
const logger = createLogger('CopilotBillingEffect')
|
||||
|
||||
/**
|
||||
* Handle a 402 billing-limit response from the Go backend.
|
||||
*
|
||||
* Determines whether the user needs a plan upgrade or a limit increase,
|
||||
* then dispatches synthetic text + complete events through the handler chain
|
||||
* so the client renders the upgrade prompt.
|
||||
*/
|
||||
export async function handleBillingLimitResponse(
|
||||
userId: string,
|
||||
context: StreamingContext,
|
||||
execContext: ExecutionContext,
|
||||
options: OrchestratorOptions
|
||||
): Promise<void> {
|
||||
let action: 'upgrade_plan' | 'increase_limit' = 'upgrade_plan'
|
||||
let message = "You've reached your usage limit. Please upgrade your plan to continue."
|
||||
try {
|
||||
const sub = await getHighestPrioritySubscription(userId)
|
||||
if (sub && isPaid(sub.plan)) {
|
||||
// Paid subs use the existing `increase_limit` action so the UI
|
||||
// (`UsageUpgradeDisplay`) renders its standard button. The message
|
||||
// text does the work of clarifying the action when the user can't
|
||||
// actually self-serve the limit change.
|
||||
action = 'increase_limit'
|
||||
const orgScoped = isOrgScopedSubscription(sub, userId)
|
||||
if (orgScoped) {
|
||||
message = isEnterprise(sub.plan)
|
||||
? "You've reached your organization's usage limit for this billing period. Only an organization admin or Sim support can raise an enterprise limit — reach out to them to continue."
|
||||
: "You've reached your organization's usage limit for this billing period. Only an organization owner or admin can raise the limit — please ask them to update it from the team billing settings."
|
||||
} else {
|
||||
message =
|
||||
"You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan')
|
||||
}
|
||||
|
||||
const upgradePayload = JSON.stringify({
|
||||
reason: 'usage_limit',
|
||||
action,
|
||||
message,
|
||||
})
|
||||
const syntheticContent = `<usage_upgrade>${upgradePayload}</usage_upgrade>`
|
||||
|
||||
const syntheticEvents: StreamEvent[] = [
|
||||
{
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: {
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
text: syntheticContent,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: MothershipStreamV1EventType.complete,
|
||||
payload: {
|
||||
status: MothershipStreamV1CompletionStatus.complete,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const event of syntheticEvents) {
|
||||
try {
|
||||
await options.onEvent?.(event)
|
||||
} catch {
|
||||
logger.warn('Failed to forward synthetic billing event', { type: event.type })
|
||||
}
|
||||
|
||||
// TODO: Handler dispatch should move out of this effect — effects should be
|
||||
// pure side-effect producers; event dispatch belongs in the stream loop or
|
||||
// a dedicated dispatcher. Keeping here for now to preserve behavior.
|
||||
const handler = sseHandlers[event.type]
|
||||
if (handler) {
|
||||
await handler(event, context, execContext, options)
|
||||
}
|
||||
if (context.streamComplete) break
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
ASYNC_TOOL_CONFIRMATION_STATUS,
|
||||
type AsyncTerminalCompletionSnapshot,
|
||||
isAsyncTerminalConfirmationStatus,
|
||||
} from '@/lib/copilot/async-runs/lifecycle'
|
||||
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
|
||||
|
||||
/**
|
||||
* Wait for a client-executable workflow tool to report back.
|
||||
*
|
||||
* Current browser runtime outcomes are:
|
||||
* - `success`, `error`, `cancelled`: the workflow finished in the browser
|
||||
* - `background`: the browser detached on `pagehide`, so the server should stop
|
||||
* waiting for a foreground result
|
||||
*/
|
||||
export async function waitForToolCompletion(
|
||||
toolCallId: string,
|
||||
timeoutMs: number,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<AsyncTerminalCompletionSnapshot | null> {
|
||||
const decision = await waitForToolConfirmation(toolCallId, timeoutMs, abortSignal, {
|
||||
acceptStatus: (status) =>
|
||||
status === MothershipStreamV1ToolOutcome.success ||
|
||||
status === MothershipStreamV1ToolOutcome.error ||
|
||||
status === ASYNC_TOOL_CONFIRMATION_STATUS.background ||
|
||||
status === MothershipStreamV1ToolOutcome.cancelled,
|
||||
})
|
||||
if (decision && isAsyncTerminalConfirmationStatus(decision.status)) {
|
||||
return { ...decision, status: decision.status }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import type {
|
||||
AsyncCompletionEnvelope,
|
||||
AsyncCompletionSignal,
|
||||
} from '@/lib/copilot/async-runs/lifecycle'
|
||||
import {
|
||||
completeAsyncToolCall,
|
||||
markAsyncToolRunning,
|
||||
upsertAsyncToolCall,
|
||||
} from '@/lib/copilot/async-runs/repository'
|
||||
import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants'
|
||||
import {
|
||||
MothershipStreamV1AsyncToolRecordStatus,
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1ToolExecutor,
|
||||
MothershipStreamV1ToolMode,
|
||||
MothershipStreamV1ToolOutcome,
|
||||
MothershipStreamV1ToolPhase,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import {
|
||||
CrawlWebsite,
|
||||
CreateFile,
|
||||
CreateWorkflow,
|
||||
DownloadToWorkspaceFile,
|
||||
EditContent,
|
||||
Ffmpeg,
|
||||
FunctionExecute,
|
||||
GenerateAudio,
|
||||
GenerateImage,
|
||||
GenerateVideo,
|
||||
KnowledgeBase,
|
||||
MaterializeFile,
|
||||
Media,
|
||||
Research,
|
||||
Run,
|
||||
RunBlock,
|
||||
RunFromBlock,
|
||||
RunWorkflow,
|
||||
RunWorkflowUntilBlock,
|
||||
WorkspaceFile,
|
||||
} from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
|
||||
import { recordSimToolMetric } from '@/lib/copilot/request/metrics'
|
||||
import { withCopilotToolSpan } from '@/lib/copilot/request/otel'
|
||||
import { markToolResultSeen } from '@/lib/copilot/request/sse-utils'
|
||||
import {
|
||||
getToolCallStateOutput,
|
||||
getToolCallTerminalData,
|
||||
requireToolCallError,
|
||||
setTerminalToolCallState,
|
||||
} from '@/lib/copilot/request/tool-call-state'
|
||||
import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files'
|
||||
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
|
||||
import {
|
||||
maybeWriteOutputToTable,
|
||||
maybeWriteReadCsvToTable,
|
||||
} from '@/lib/copilot/request/tools/tables'
|
||||
import {
|
||||
type ExecutionContext,
|
||||
isTerminalToolCallStatus,
|
||||
type OrchestratorOptions,
|
||||
type StreamEvent,
|
||||
type StreamingContext,
|
||||
type ToolCallState,
|
||||
} from '@/lib/copilot/request/types'
|
||||
import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor'
|
||||
|
||||
export { waitForToolCompletion } from '@/lib/copilot/request/tools/client'
|
||||
|
||||
const logger = createLogger('CopilotSseToolExecution')
|
||||
|
||||
function hasOutputValue(result: { output?: unknown } | undefined): result is { output: unknown } {
|
||||
return result !== undefined && Object.hasOwn(result, 'output')
|
||||
}
|
||||
|
||||
interface ToolResultSpanSummary {
|
||||
resultSuccess: boolean
|
||||
outputBytes: number
|
||||
outputKind: string
|
||||
errorMessage?: string
|
||||
imageCount?: number
|
||||
imageBytes?: number
|
||||
attachmentMediaType?: string
|
||||
}
|
||||
|
||||
function summarizeToolResultForSpan(result: {
|
||||
success: boolean
|
||||
output?: unknown
|
||||
error?: string
|
||||
}): ToolResultSpanSummary {
|
||||
const summary: ToolResultSpanSummary = {
|
||||
resultSuccess: Boolean(result.success),
|
||||
outputBytes: 0,
|
||||
outputKind: 'none',
|
||||
}
|
||||
if (!result.success && result.error) {
|
||||
summary.errorMessage = String(result.error).slice(0, 500)
|
||||
}
|
||||
if (!hasOutputValue(result)) {
|
||||
return summary
|
||||
}
|
||||
const output = (result as { output: unknown }).output
|
||||
if (typeof output === 'string') {
|
||||
summary.outputKind = 'string'
|
||||
summary.outputBytes = output.length
|
||||
} else if (output && typeof output === 'object') {
|
||||
summary.outputKind = Array.isArray(output) ? 'array' : 'object'
|
||||
try {
|
||||
summary.outputBytes = JSON.stringify(output).length
|
||||
} catch {
|
||||
summary.outputBytes = 0
|
||||
}
|
||||
const attachment = extractAttachmentShape(output)
|
||||
if (attachment) {
|
||||
summary.imageCount = attachment.imageCount
|
||||
summary.imageBytes = attachment.imageBytes
|
||||
if (attachment.mediaType) {
|
||||
summary.attachmentMediaType = attachment.mediaType
|
||||
}
|
||||
}
|
||||
} else if (output !== undefined && output !== null) {
|
||||
summary.outputKind = typeof output
|
||||
summary.outputBytes = String(output).length
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
function extractAttachmentShape(
|
||||
output: unknown
|
||||
): { imageCount: number; imageBytes: number; mediaType?: string } | null {
|
||||
if (!isRecordLike(output)) return null
|
||||
const candidate = (output as Record<string, unknown>).attachment
|
||||
if (!isRecordLike(candidate)) return null
|
||||
const source = (candidate as Record<string, unknown>).source
|
||||
if (!isRecordLike(source)) return null
|
||||
const type =
|
||||
typeof (candidate as Record<string, unknown>).type === 'string'
|
||||
? ((candidate as Record<string, unknown>).type as string)
|
||||
: ''
|
||||
if (type !== 'image') return null
|
||||
const mediaType =
|
||||
typeof source.media_type === 'string' ? (source.media_type as string) : undefined
|
||||
const data = typeof source.data === 'string' ? (source.data as string) : ''
|
||||
return {
|
||||
imageCount: 1,
|
||||
imageBytes: data.length,
|
||||
mediaType,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletionSignal(input: {
|
||||
status: AsyncCompletionSignal['status']
|
||||
message?: string
|
||||
data?: unknown
|
||||
}): AsyncCompletionSignal {
|
||||
return {
|
||||
status: input.status,
|
||||
...(input.message !== undefined ? { message: input.message } : {}),
|
||||
...(input.data !== undefined ? { data: input.data } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function getCreateWorkflowOutput(
|
||||
output: unknown
|
||||
): { workflowId?: string; workspaceId?: string } | undefined {
|
||||
if (!isRecordLike(output)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const workflowId = typeof output.workflowId === 'string' ? output.workflowId : undefined
|
||||
const workspaceId = typeof output.workspaceId === 'string' ? output.workspaceId : undefined
|
||||
if (!workflowId && !workspaceId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export interface AsyncToolCompletion extends AsyncCompletionSignal {}
|
||||
|
||||
function publishTerminalToolConfirmation(input: {
|
||||
toolCallId: string
|
||||
status: AsyncCompletionEnvelope['status']
|
||||
message?: string
|
||||
data?: unknown
|
||||
}): void {
|
||||
publishToolConfirmation({
|
||||
toolCallId: input.toolCallId,
|
||||
status: input.status,
|
||||
message: input.message,
|
||||
data: input.data,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
function abortRequested(
|
||||
context: StreamingContext,
|
||||
execContext: ExecutionContext,
|
||||
options?: OrchestratorOptions
|
||||
): boolean {
|
||||
return Boolean(
|
||||
options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool classes whose legitimate runtime can far exceed the default watchdog:
|
||||
* workflow executions, sandboxed code, media/image/audio generation, deep
|
||||
* research, large downloads, knowledge-base indexing, and file-content
|
||||
* producers (create/edit/materialize hit the E2B doc compile/recalc/render
|
||||
* pipeline on doc-backed files). They get the long watchdog cap; everything
|
||||
* else (read/glob/grep/metadata CRUD/...) must settle within the strict
|
||||
* default or be failed so the run can continue.
|
||||
*/
|
||||
const LONG_RUNNING_TOOL_IDS: ReadonlySet<string> = new Set([
|
||||
Run.id,
|
||||
RunBlock.id,
|
||||
RunFromBlock.id,
|
||||
RunWorkflow.id,
|
||||
RunWorkflowUntilBlock.id,
|
||||
FunctionExecute.id,
|
||||
GenerateImage.id,
|
||||
GenerateAudio.id,
|
||||
GenerateVideo.id,
|
||||
Ffmpeg.id,
|
||||
Media.id,
|
||||
Research.id,
|
||||
CrawlWebsite.id,
|
||||
KnowledgeBase.id,
|
||||
DownloadToWorkspaceFile.id,
|
||||
CreateFile.id,
|
||||
EditContent.id,
|
||||
MaterializeFile.id,
|
||||
WorkspaceFile.id,
|
||||
])
|
||||
|
||||
export function toolWatchdogTimeoutMs(toolName: string | undefined): number {
|
||||
return toolName && LONG_RUNNING_TOOL_IDS.has(toolName)
|
||||
? TOOL_WATCHDOG_LONG_RUNNING_MS
|
||||
: TOOL_WATCHDOG_DEFAULT_MS
|
||||
}
|
||||
|
||||
class ToolExecutionTimeoutError extends Error {
|
||||
constructor(toolName: string, timeoutMs: number) {
|
||||
super(
|
||||
`Tool '${toolName}' timed out after ${Math.round(timeoutMs / 1000)}s on the Sim executor and was abandoned.`
|
||||
)
|
||||
this.name = 'ToolExecutionTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool with a hard settlement guarantee. If the handler neither
|
||||
* resolves nor rejects within the tool's watchdog cap, throw a timeout error
|
||||
* so the standard failure path (persist failed row, publish terminal
|
||||
* confirmation, resume Go with an error result) runs and the chat never
|
||||
* wedges behind a hung await. The losing promise keeps running detached; its
|
||||
* eventual settlement is ignored.
|
||||
*/
|
||||
async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) {
|
||||
const timeoutMs = toolWatchdogTimeoutMs(toolCall.name)
|
||||
// Thread the invoking subagent's channel id per call (execContext is shared
|
||||
// across the whole turn, so the channel id can't live on it) — server tools
|
||||
// use it to scope the workspace_file -> edit_content intent handoff.
|
||||
const toolContext = toolCall.parentToolCallId
|
||||
? { ...execContext, parentToolCallId: toolCall.parentToolCallId }
|
||||
: execContext
|
||||
const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
execution,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new ToolExecutionTimeoutError(toolCall.name, timeoutMs)),
|
||||
timeoutMs
|
||||
)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
// Swallow the abandoned promise's eventual rejection so it can't surface
|
||||
// as an unhandled rejection after a watchdog loss.
|
||||
execution.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort settlement for a tool whose promise never settled (a hang the
|
||||
* per-tool watchdog could not see, e.g. in post-processing or persistence).
|
||||
* Records a terminal error state + failed async row so the checkpoint loop
|
||||
* can resume Go with an error result instead of waiting forever.
|
||||
*/
|
||||
export async function forceFailHungToolCall(
|
||||
toolCallId: string,
|
||||
context: StreamingContext,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
const toolCall = context.toolCalls.get(toolCallId)
|
||||
if (!toolCall || toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) return
|
||||
setTerminalToolCallState(toolCall, {
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
error: message,
|
||||
})
|
||||
logger.error('Force-failed hung tool call', {
|
||||
toolCallId,
|
||||
toolName: toolCall.name,
|
||||
message,
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.failed,
|
||||
result: { error: message },
|
||||
error: message,
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist force-failed async tool status', {
|
||||
toolCallId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId,
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
message,
|
||||
data: { error: message },
|
||||
})
|
||||
}
|
||||
|
||||
function cancelledCompletion(message: string): AsyncToolCompletion {
|
||||
return buildCompletionSignal({
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
message,
|
||||
data: { cancelled: true },
|
||||
})
|
||||
}
|
||||
|
||||
function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompletion {
|
||||
if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) {
|
||||
return cancelledCompletion(requireToolCallError(toolCall))
|
||||
}
|
||||
|
||||
if (toolCall.status === MothershipStreamV1ToolOutcome.success) {
|
||||
const data = getToolCallStateOutput(toolCall)
|
||||
return buildCompletionSignal({
|
||||
status: MothershipStreamV1ToolOutcome.success,
|
||||
message: 'Tool completed',
|
||||
...(data !== undefined ? { data } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
if (toolCall.status === MothershipStreamV1ToolOutcome.skipped) {
|
||||
const data = getToolCallStateOutput(toolCall)
|
||||
return buildCompletionSignal({
|
||||
status: MothershipStreamV1ToolOutcome.success,
|
||||
message: 'Tool skipped',
|
||||
...(data !== undefined ? { data } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const terminalErrorMessage = requireToolCallError(toolCall)
|
||||
return buildCompletionSignal({
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
message: terminalErrorMessage,
|
||||
data: getToolCallTerminalData(toolCall),
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeToolAndReport(
|
||||
toolCallId: string,
|
||||
context: StreamingContext,
|
||||
execContext: ExecutionContext,
|
||||
options?: OrchestratorOptions
|
||||
): Promise<AsyncToolCompletion> {
|
||||
const toolCall = context.toolCalls.get(toolCallId)
|
||||
if (!toolCall)
|
||||
return buildCompletionSignal({
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
message: 'Tool call not found',
|
||||
})
|
||||
|
||||
const argsPayload = toolCall.params
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.stringify(toolCall.params)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
return withCopilotToolSpan(
|
||||
{
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
runId: context.runId,
|
||||
chatId: execContext.chatId,
|
||||
argsBytes: argsPayload?.length,
|
||||
argsPreview: argsPayload?.slice(0, 200),
|
||||
},
|
||||
async (otelSpan) => {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const completion = await executeToolAndReportInner(toolCall, context, execContext, options)
|
||||
const durationMs = Date.now() - startedAt
|
||||
otelSpan.setAttribute(TraceAttr.ToolOutcome, completion.status)
|
||||
otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs)
|
||||
if (completion.message) {
|
||||
otelSpan.setAttribute(
|
||||
TraceAttr.ToolOutcomeMessage,
|
||||
String(completion.message).slice(0, 500)
|
||||
)
|
||||
}
|
||||
// Durable Grafana signal for "which Sim tool is slowest" (executor=sim);
|
||||
// pairs with the Go executor-boundary metric (U15) as one series set.
|
||||
recordSimToolMetric(toolCall.name, completion.status, durationMs)
|
||||
return completion
|
||||
} catch (err) {
|
||||
// executeToolAndReportInner threw (infra/unexpected error, not a normal
|
||||
// 'error' completion). Still stamp the span + record the dispatch so
|
||||
// copilot.tool.* isn't silently biased toward successful calls.
|
||||
const durationMs = Date.now() - startedAt
|
||||
otelSpan.setAttribute(TraceAttr.ToolOutcome, 'error')
|
||||
otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs)
|
||||
recordSimToolMetric(toolCall.name, 'error', durationMs)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function executeToolAndReportInner(
|
||||
toolCall: ToolCallState,
|
||||
context: StreamingContext,
|
||||
execContext: ExecutionContext,
|
||||
options?: OrchestratorOptions
|
||||
): Promise<AsyncToolCompletion> {
|
||||
if (toolCall.status === 'executing') {
|
||||
return buildCompletionSignal({
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.running,
|
||||
message: 'Tool already executing',
|
||||
})
|
||||
}
|
||||
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
|
||||
return terminalCompletionFromToolCall(toolCall)
|
||||
}
|
||||
|
||||
const markToolCallCancelled = (message: string) => {
|
||||
setTerminalToolCallState(toolCall, {
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
error: message,
|
||||
})
|
||||
}
|
||||
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted before tool execution')
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
|
||||
result: { cancelled: true },
|
||||
error: 'Request aborted before tool execution',
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool status', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
message: 'Request aborted before tool execution',
|
||||
data: { cancelled: true },
|
||||
})
|
||||
return cancelledCompletion('Request aborted before tool execution')
|
||||
}
|
||||
|
||||
toolCall.status = 'executing'
|
||||
await upsertAsyncToolCall({
|
||||
runId: context.runId,
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
args: toolCall.params,
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool row before execution', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => {
|
||||
logger.warn('Failed to mark async tool running', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
|
||||
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
|
||||
return terminalCompletionFromToolCall(toolCall)
|
||||
}
|
||||
|
||||
const argsPreview = toolCall.params ? JSON.stringify(toolCall.params).slice(0, 200) : undefined
|
||||
const toolSpan = context.trace.startSpan(toolCall.name, 'tool.execute', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
argsPreview,
|
||||
abortSignalAborted: execContext.abortSignal?.aborted ?? false,
|
||||
})
|
||||
|
||||
const endToolSpan = (
|
||||
status: string,
|
||||
detail?: { error?: string; cancelReason?: string; resultSuccess?: boolean }
|
||||
) => {
|
||||
const abortDetail: Record<string, unknown> = {}
|
||||
if (execContext.abortSignal?.aborted) {
|
||||
abortDetail.abortSignalAborted = true
|
||||
abortDetail.abortReason = String(execContext.abortSignal.reason ?? 'unknown')
|
||||
}
|
||||
if (options?.abortSignal?.aborted) {
|
||||
abortDetail.optionsAbortReason = String(options.abortSignal.reason ?? 'unknown')
|
||||
}
|
||||
if (context.wasAborted) {
|
||||
abortDetail.wasAborted = true
|
||||
}
|
||||
toolSpan.attributes = { ...toolSpan.attributes, ...abortDetail, ...detail }
|
||||
context.trace.endSpan(toolSpan, status)
|
||||
}
|
||||
const endToolSpanFromTerminalState = () => {
|
||||
const terminalStatus =
|
||||
toolCall.status === MothershipStreamV1ToolOutcome.cancelled
|
||||
? 'cancelled'
|
||||
: toolCall.status === MothershipStreamV1ToolOutcome.success ||
|
||||
toolCall.status === MothershipStreamV1ToolOutcome.skipped
|
||||
? 'ok'
|
||||
: 'error'
|
||||
endToolSpan(terminalStatus, {
|
||||
resultSuccess: toolCall.status === MothershipStreamV1ToolOutcome.success,
|
||||
...(toolCall.error ? { error: toolCall.error } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Tool execution started', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
})
|
||||
|
||||
try {
|
||||
ensureHandlersRegistered()
|
||||
let result = await executeToolWithWatchdog(toolCall, execContext)
|
||||
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
|
||||
endToolSpanFromTerminalState()
|
||||
return terminalCompletionFromToolCall(toolCall)
|
||||
}
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted during tool execution')
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
|
||||
result: { cancelled: true },
|
||||
error: 'Request aborted during tool execution',
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool status', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
message: 'Request aborted during tool execution',
|
||||
data: { cancelled: true },
|
||||
})
|
||||
endToolSpan('cancelled', {
|
||||
cancelReason: 'abort_during_execution',
|
||||
error: result.success === false ? result.error : undefined,
|
||||
})
|
||||
return cancelledCompletion('Request aborted during tool execution')
|
||||
}
|
||||
result = await maybeWriteOutputToFile(toolCall.name, toolCall.params, result, execContext)
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted during tool post-processing')
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
|
||||
result: { cancelled: true },
|
||||
error: 'Request aborted during tool post-processing',
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool status', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
message: 'Request aborted during tool post-processing',
|
||||
data: { cancelled: true },
|
||||
})
|
||||
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_file' })
|
||||
return cancelledCompletion('Request aborted during tool post-processing')
|
||||
}
|
||||
result = await maybeWriteOutputToTable(toolCall.name, toolCall.params, result, execContext)
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted during tool post-processing')
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
|
||||
result: { cancelled: true },
|
||||
error: 'Request aborted during tool post-processing',
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool status', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
message: 'Request aborted during tool post-processing',
|
||||
data: { cancelled: true },
|
||||
})
|
||||
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_table' })
|
||||
return cancelledCompletion('Request aborted during tool post-processing')
|
||||
}
|
||||
result = await maybeWriteReadCsvToTable(toolCall.name, toolCall.params, result, execContext)
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted during tool post-processing')
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
|
||||
result: { cancelled: true },
|
||||
error: 'Request aborted during tool post-processing',
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool status', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
message: 'Request aborted during tool post-processing',
|
||||
data: { cancelled: true },
|
||||
})
|
||||
endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' })
|
||||
return cancelledCompletion('Request aborted during tool post-processing')
|
||||
}
|
||||
toolSpan.attributes = {
|
||||
...toolSpan.attributes,
|
||||
...summarizeToolResultForSpan(result),
|
||||
}
|
||||
|
||||
setTerminalToolCallState(toolCall, {
|
||||
status: result.success
|
||||
? MothershipStreamV1ToolOutcome.success
|
||||
: MothershipStreamV1ToolOutcome.error,
|
||||
...(hasOutputValue(result) ? { output: result.output } : {}),
|
||||
...(result.success ? {} : { error: result.error || 'Tool failed' }),
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const raw = result.output
|
||||
const preview =
|
||||
typeof raw === 'string'
|
||||
? raw.slice(0, 200)
|
||||
: raw && typeof raw === 'object'
|
||||
? JSON.stringify(raw).slice(0, 200)
|
||||
: undefined
|
||||
logger.info('Tool execution succeeded', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
outputPreview: preview,
|
||||
})
|
||||
} else {
|
||||
logger.warn('Tool execution failed', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: result.error,
|
||||
params: toolCall.params,
|
||||
})
|
||||
}
|
||||
|
||||
// If create_workflow was successful, update the execution context with the new workflowId.
|
||||
// This ensures subsequent tools in the same stream have access to the workflowId.
|
||||
const createWorkflowOutput = getCreateWorkflowOutput(result.output)
|
||||
if (
|
||||
toolCall.name === CreateWorkflow.id &&
|
||||
result.success &&
|
||||
createWorkflowOutput?.workflowId &&
|
||||
!execContext.workflowId
|
||||
) {
|
||||
execContext.workflowId = createWorkflowOutput.workflowId
|
||||
if (createWorkflowOutput.workspaceId) {
|
||||
execContext.workspaceId = createWorkflowOutput.workspaceId
|
||||
}
|
||||
}
|
||||
|
||||
const terminalStatus = result.success
|
||||
? MothershipStreamV1ToolOutcome.success
|
||||
: MothershipStreamV1ToolOutcome.error
|
||||
const terminalMessage = result.success ? 'Tool completed' : requireToolCallError(toolCall)
|
||||
const terminalData = getToolCallTerminalData(toolCall)
|
||||
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: result.success
|
||||
? MothershipStreamV1AsyncToolRecordStatus.completed
|
||||
: MothershipStreamV1AsyncToolRecordStatus.failed,
|
||||
...(terminalData !== undefined ? { result: terminalData } : {}),
|
||||
error: result.success ? null : terminalMessage,
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool completion', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: terminalStatus,
|
||||
message: terminalMessage,
|
||||
...(terminalData !== undefined ? { data: terminalData } : {}),
|
||||
})
|
||||
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted before tool result delivery')
|
||||
endToolSpan('cancelled', { cancelReason: 'abort_before_tool_result_delivery' })
|
||||
return cancelledCompletion('Request aborted before tool result delivery')
|
||||
}
|
||||
|
||||
// Fire-and-forget: notify the copilot backend that the tool completed.
|
||||
// IMPORTANT: We must NOT await this — the Go backend may block on the
|
||||
const resultEvent: StreamEvent = {
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
executor: MothershipStreamV1ToolExecutor.sim,
|
||||
mode: MothershipStreamV1ToolMode.async,
|
||||
phase: MothershipStreamV1ToolPhase.result,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
...(result.success
|
||||
? { status: MothershipStreamV1ToolOutcome.success }
|
||||
: { status: MothershipStreamV1ToolOutcome.error }),
|
||||
},
|
||||
}
|
||||
await options?.onEvent?.(resultEvent)
|
||||
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted before resource persistence')
|
||||
endToolSpan('cancelled', { cancelReason: 'abort_before_resource_persistence' })
|
||||
return cancelledCompletion('Request aborted before resource persistence')
|
||||
}
|
||||
|
||||
if (result.success && execContext.chatId && !abortRequested(context, execContext, options)) {
|
||||
await handleResourceSideEffects(
|
||||
toolCall.name,
|
||||
toolCall.params,
|
||||
result,
|
||||
execContext.chatId,
|
||||
options?.onEvent,
|
||||
() => abortRequested(context, execContext, options)
|
||||
)
|
||||
}
|
||||
endToolSpan(result.success ? 'ok' : 'error', {
|
||||
resultSuccess: result.success,
|
||||
...(result.success ? {} : { error: terminalMessage }),
|
||||
})
|
||||
return buildCompletionSignal({
|
||||
status: terminalStatus,
|
||||
message: terminalMessage,
|
||||
...(terminalData !== undefined ? { data: terminalData } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
const thrownMessage = toError(error).message
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
markToolCallCancelled('Request aborted during tool execution')
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.cancelled,
|
||||
result: { cancelled: true },
|
||||
error: 'Request aborted during tool execution',
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool status', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1ToolOutcome.cancelled,
|
||||
message: 'Request aborted during tool execution',
|
||||
data: { cancelled: true },
|
||||
})
|
||||
endToolSpan('cancelled', {
|
||||
cancelReason: 'abort_during_execution_catch',
|
||||
error: thrownMessage,
|
||||
})
|
||||
return cancelledCompletion('Request aborted during tool execution')
|
||||
}
|
||||
setTerminalToolCallState(toolCall, {
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
error: thrownMessage,
|
||||
})
|
||||
|
||||
logger.error('Tool execution threw', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: toolCall.error,
|
||||
params: toolCall.params,
|
||||
})
|
||||
|
||||
markToolResultSeen(toolCall.id)
|
||||
await completeAsyncToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1AsyncToolRecordStatus.failed,
|
||||
result: { error: toolCall.error },
|
||||
error: toolCall.error,
|
||||
}).catch((err) => {
|
||||
logger.warn('Failed to persist async tool error', {
|
||||
toolCallId: toolCall.id,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
publishTerminalToolConfirmation({
|
||||
toolCallId: toolCall.id,
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
message: toolCall.error,
|
||||
data: { error: toolCall.error },
|
||||
})
|
||||
|
||||
const errorEvent: StreamEvent = {
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
executor: MothershipStreamV1ToolExecutor.sim,
|
||||
mode: MothershipStreamV1ToolMode.async,
|
||||
phase: MothershipStreamV1ToolPhase.result,
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
success: false,
|
||||
error: toolCall.error,
|
||||
output: { error: toolCall.error },
|
||||
},
|
||||
}
|
||||
await options?.onEvent?.(errorEvent)
|
||||
endToolSpan('error', { error: thrownMessage })
|
||||
return buildCompletionSignal({
|
||||
status: MothershipStreamV1ToolOutcome.error,
|
||||
message: toolCall.error,
|
||||
data: { error: toolCall.error },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({
|
||||
mockWriteWorkspaceFileByPath: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
|
||||
writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/otel', () => ({
|
||||
withCopilotSpan: (
|
||||
_name: string,
|
||||
_attrs: Record<string, unknown> | undefined,
|
||||
fn: (span: unknown) => Promise<unknown>
|
||||
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }),
|
||||
}))
|
||||
|
||||
import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import {
|
||||
extractTabularData,
|
||||
maybeWriteOutputToFile,
|
||||
normalizeOutputWorkspaceFileName,
|
||||
serializeOutputForFile,
|
||||
unwrapFunctionExecuteOutput,
|
||||
} from '@/lib/copilot/request/tools/files'
|
||||
import type { ExecutionContext } from '@/lib/copilot/request/types'
|
||||
|
||||
describe('unwrapFunctionExecuteOutput', () => {
|
||||
it('unwraps the function_execute envelope { result, stdout }', () => {
|
||||
expect(unwrapFunctionExecuteOutput({ result: 'name,age\nAlice,30', stdout: '' })).toBe(
|
||||
'name,age\nAlice,30'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes through objects that do not have both result + stdout', () => {
|
||||
const output = { data: { rows: [], totalCount: 0 } }
|
||||
expect(unwrapFunctionExecuteOutput(output)).toBe(output)
|
||||
})
|
||||
|
||||
it('passes through strings and arrays untouched', () => {
|
||||
expect(unwrapFunctionExecuteOutput('hello')).toBe('hello')
|
||||
const arr: unknown[] = [{ a: 1 }]
|
||||
expect(unwrapFunctionExecuteOutput(arr)).toBe(arr)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeOutputForFile (csv)', () => {
|
||||
it('returns raw CSV text when function_execute result is already a CSV string', () => {
|
||||
const output = {
|
||||
result: 'name,age\nAlice,30\nBob,40',
|
||||
stdout: '(2 rows)',
|
||||
}
|
||||
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
|
||||
})
|
||||
|
||||
it('converts a result array of objects into CSV', () => {
|
||||
const output = {
|
||||
result: [
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 40 },
|
||||
],
|
||||
stdout: '',
|
||||
}
|
||||
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
|
||||
})
|
||||
|
||||
it('returns the raw string when the non-envelope output is already a CSV string', () => {
|
||||
expect(serializeOutputForFile('a,b\n1,2', 'csv')).toBe('a,b\n1,2')
|
||||
})
|
||||
|
||||
it('falls back to JSON.stringify when the payload is not tabular and not a string', () => {
|
||||
const output = { result: { foo: 'bar' }, stdout: '' }
|
||||
expect(serializeOutputForFile(output, 'csv')).toBe('{\n "foo": "bar"\n}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeOutputForFile (json / txt / md)', () => {
|
||||
it('unwraps the envelope for json format so the file contains only result', () => {
|
||||
const output = { result: { hello: 'world' }, stdout: 'log' }
|
||||
expect(serializeOutputForFile(output, 'json')).toBe('{\n "hello": "world"\n}')
|
||||
})
|
||||
|
||||
it('returns the string payload as-is for txt/md/html formats', () => {
|
||||
const output = { result: '# Report\n\nHello', stdout: '' }
|
||||
expect(serializeOutputForFile(output, 'md')).toBe('# Report\n\nHello')
|
||||
expect(serializeOutputForFile(output, 'txt')).toBe('# Report\n\nHello')
|
||||
expect(serializeOutputForFile(output, 'html')).toBe('# Report\n\nHello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeOutputWorkspaceFileName', () => {
|
||||
it('derives the leaf file name from workflow alias output paths', () => {
|
||||
expect(normalizeOutputWorkspaceFileName('workflows/My%20Workflow/changelog.md')).toBe(
|
||||
'changelog.md'
|
||||
)
|
||||
expect(
|
||||
normalizeOutputWorkspaceFileName('workflows/My%20Workflow/.plans/phase%201/implementation.md')
|
||||
).toBe('implementation.md')
|
||||
})
|
||||
|
||||
it('still handles normal workspace file output paths', () => {
|
||||
expect(normalizeOutputWorkspaceFileName('files/Reports/output.csv')).toBe('output.csv')
|
||||
})
|
||||
})
|
||||
|
||||
describe('maybeWriteOutputToFile', () => {
|
||||
function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'workspace-1',
|
||||
userPermission: 'write',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWriteWorkspaceFileByPath.mockResolvedValue({
|
||||
id: 'file-1',
|
||||
name: 'report.csv',
|
||||
vfsPath: 'files/report.csv',
|
||||
mode: 'overwrite',
|
||||
})
|
||||
})
|
||||
|
||||
it('denies a read-only principal without writing the file', async () => {
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
|
||||
buildContext({ userPermission: 'read' })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('requires write access')
|
||||
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not deny a read-only principal when no workspace write occurs (sandbox export active)', async () => {
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: { files: [{ path: 'report.csv' }] }, stdout: '' } },
|
||||
buildContext({ userPermission: 'read' })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes the output file for a write principal', async () => {
|
||||
const result = await maybeWriteOutputToFile(
|
||||
FunctionExecute.id,
|
||||
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
|
||||
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTabularData', () => {
|
||||
it('extracts rows directly from an array input', () => {
|
||||
expect(extractTabularData([{ a: 1 }, { a: 2 }])).toEqual([{ a: 1 }, { a: 2 }])
|
||||
})
|
||||
|
||||
it('does NOT unwrap function_execute envelopes on its own (callers must pre-unwrap)', () => {
|
||||
// Caller is responsible for unwrapping { result, stdout } envelopes first.
|
||||
// Keeping that concern out of this function prevents a double unwrap when
|
||||
// the user's payload itself happens to have matching keys.
|
||||
expect(extractTabularData({ result: [{ a: 1 }], stdout: '' })).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts rows from the user_table query_rows shape', () => {
|
||||
const rows = extractTabularData({
|
||||
data: {
|
||||
rows: [
|
||||
{ id: 'row_1', data: { name: 'Alice' } },
|
||||
{ id: 'row_2', data: { name: 'Bob' } },
|
||||
],
|
||||
totalCount: 2,
|
||||
},
|
||||
})
|
||||
expect(rows).toEqual([{ name: 'Alice' }, { name: 'Bob' }])
|
||||
})
|
||||
|
||||
it('returns null for non-tabular inputs', () => {
|
||||
expect(extractTabularData('plain string')).toBeNull()
|
||||
expect(extractTabularData(null)).toBeNull()
|
||||
expect(extractTabularData({ foo: 'bar' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
||||
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
|
||||
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
|
||||
|
||||
const logger = createLogger('CopilotToolResultFiles')
|
||||
|
||||
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([FunctionExecute.id, UserTable.id])
|
||||
|
||||
export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html'
|
||||
|
||||
export const EXT_TO_FORMAT: Record<string, OutputFormat> = {
|
||||
'.json': 'json',
|
||||
'.csv': 'csv',
|
||||
'.txt': 'txt',
|
||||
'.md': 'md',
|
||||
'.html': 'html',
|
||||
}
|
||||
|
||||
export const FORMAT_TO_CONTENT_TYPE: Record<OutputFormat, string> = {
|
||||
json: 'application/json',
|
||||
csv: 'text/csv',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
html: 'text/html',
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps the `function_execute` response envelope `{ result, stdout }` so the
|
||||
* rest of the serialization code works on the user's actual payload (a string,
|
||||
* array, object, etc.) instead of JSON-stringifying the envelope itself.
|
||||
*
|
||||
* Only unwraps when both keys are present — that's the unique shape of
|
||||
* `function_execute` (see `apps/sim/tools/function/types.ts` `CodeExecutionOutput`).
|
||||
* `user_table` returns `{ data, message, success }` which is left alone.
|
||||
*/
|
||||
export function unwrapFunctionExecuteOutput(output: unknown): unknown {
|
||||
if (!output || typeof output !== 'object' || Array.isArray(output)) return output
|
||||
const obj = output as Record<string, unknown>
|
||||
if ('result' in obj && 'stdout' in obj) {
|
||||
return obj.result
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to pull a flat array of row-objects out of an already-unwrapped tool
|
||||
* payload. Callers are responsible for stripping any `function_execute`
|
||||
* envelope first (via {@link unwrapFunctionExecuteOutput}) — this function
|
||||
* does not re-unwrap, so a user payload that coincidentally has `result` and
|
||||
* `stdout` keys is not mistaken for another envelope.
|
||||
*/
|
||||
export function extractTabularData(output: unknown): Record<string, unknown>[] | null {
|
||||
if (!output || typeof output !== 'object') return null
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
if (output.length > 0 && typeof output[0] === 'object' && output[0] !== null) {
|
||||
return output as Record<string, unknown>[]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const obj = output as Record<string, unknown>
|
||||
|
||||
// user_table query_rows shape: { data: { rows: [{ data: {...} }], totalCount } }
|
||||
if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
|
||||
const data = obj.data as Record<string, unknown>
|
||||
if (Array.isArray(data.rows) && data.rows.length > 0) {
|
||||
const rows = data.rows as Record<string, unknown>[]
|
||||
if (typeof rows[0].data === 'object' && rows[0].data !== null) {
|
||||
return rows.map((r) => r.data as Record<string, unknown>)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function escapeCsvValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export function convertRowsToCsv(rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) return ''
|
||||
|
||||
const headerSet = new Set<string>()
|
||||
for (const row of rows) {
|
||||
for (const key of Object.keys(row)) {
|
||||
headerSet.add(key)
|
||||
}
|
||||
}
|
||||
const headers = [...headerSet]
|
||||
|
||||
const lines = [headers.map(escapeCsvValue).join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((h) => escapeCsvValue(row[h])).join(','))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function normalizeOutputWorkspaceFileName(outputPath: string): string {
|
||||
const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, ''))
|
||||
const fileName = segments.at(-1)
|
||||
if (!fileName) {
|
||||
throw new Error('Output path must include a file name')
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
export function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat {
|
||||
if (explicit && explicit in FORMAT_TO_CONTENT_TYPE) return explicit as OutputFormat
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
|
||||
return EXT_TO_FORMAT[ext] ?? 'json'
|
||||
}
|
||||
|
||||
export function serializeOutputForFile(output: unknown, format: OutputFormat): string {
|
||||
const unwrapped = unwrapFunctionExecuteOutput(output)
|
||||
|
||||
if (typeof unwrapped === 'string') return unwrapped
|
||||
|
||||
if (format === 'csv') {
|
||||
const rows = extractTabularData(unwrapped)
|
||||
if (rows && rows.length > 0) {
|
||||
return convertRowsToCsv(rows)
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(unwrapped, null, 2)
|
||||
}
|
||||
|
||||
export interface OutputFileDeclaration {
|
||||
path: string
|
||||
mode?: 'create' | 'overwrite'
|
||||
format?: OutputFormat
|
||||
mimeType?: string
|
||||
sandboxPath?: string
|
||||
formatPath?: string
|
||||
}
|
||||
|
||||
export function getOutputFileDeclarations(
|
||||
params: Record<string, unknown> | undefined
|
||||
): OutputFileDeclaration[] {
|
||||
const args = params?.args as Record<string, unknown> | undefined
|
||||
const outputs =
|
||||
(params?.outputs as { files?: unknown[] } | undefined) ??
|
||||
(args?.outputs as { files?: unknown[] } | undefined)
|
||||
|
||||
if (Array.isArray(outputs?.files)) {
|
||||
return outputs.files.flatMap((item): OutputFileDeclaration[] => {
|
||||
if (!item || typeof item !== 'object') return []
|
||||
const file = item as Record<string, unknown>
|
||||
if (typeof file.path !== 'string') return []
|
||||
return [
|
||||
{
|
||||
path: file.path,
|
||||
mode: file.mode === 'overwrite' ? 'overwrite' : 'create',
|
||||
format: typeof file.format === 'string' ? (file.format as OutputFormat) : undefined,
|
||||
mimeType: typeof file.mimeType === 'string' ? file.mimeType : undefined,
|
||||
sandboxPath: typeof file.sandboxPath === 'string' ? file.sandboxPath : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const outputPath =
|
||||
(params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined)
|
||||
if (!outputPath) return []
|
||||
const overwriteFileId =
|
||||
(params?.overwriteFileId as string | undefined) ?? (args?.overwriteFileId as string | undefined)
|
||||
return [
|
||||
{
|
||||
path: overwriteFileId || outputPath,
|
||||
mode: overwriteFileId ? 'overwrite' : 'create',
|
||||
formatPath: outputPath,
|
||||
format: ((params?.outputFormat as string | undefined) ??
|
||||
(args?.outputFormat as string | undefined)) as OutputFormat | undefined,
|
||||
mimeType:
|
||||
(params?.outputMimeType as string | undefined) ??
|
||||
(args?.outputMimeType as string | undefined),
|
||||
sandboxPath:
|
||||
(params?.outputSandboxPath as string | undefined) ??
|
||||
(args?.outputSandboxPath as string | undefined),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export async function maybeWriteOutputToFile(
|
||||
toolName: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
result: ToolCallResult,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
if (!result.success || !result.output) return result
|
||||
if (!OUTPUT_PATH_TOOLS.has(toolName)) return result
|
||||
if (!context.workspaceId || !context.userId) return result
|
||||
|
||||
const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath)
|
||||
if (outputFiles.length === 0) return result
|
||||
|
||||
const outputObject =
|
||||
result.output && typeof result.output === 'object' && !Array.isArray(result.output)
|
||||
? (result.output as Record<string, unknown>)
|
||||
: undefined
|
||||
const resultObject =
|
||||
outputObject?.result &&
|
||||
typeof outputObject.result === 'object' &&
|
||||
!Array.isArray(outputObject.result)
|
||||
? (outputObject.result as Record<string, unknown>)
|
||||
: undefined
|
||||
if (Array.isArray(resultObject?.files)) {
|
||||
logger.warn('Skipping returned-value output write because sandbox export response is active', {
|
||||
toolName,
|
||||
outputCount: outputFiles.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const denied = denyOutputWriteWithoutWritePermission(context)
|
||||
if (denied) return denied
|
||||
|
||||
// Only span the actual write path (where we upload to storage). Fast
|
||||
// no-op returns above don't need a span — they'd just pad the trace
|
||||
// with empty work.
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotToolsWriteOutputFile,
|
||||
{
|
||||
[TraceAttr.ToolName]: toolName,
|
||||
[TraceAttr.WorkspaceId]: context.workspaceId,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const writtenFiles = []
|
||||
for (const outputFile of outputFiles) {
|
||||
const fileName = normalizeOutputWorkspaceFileName(
|
||||
outputFile.formatPath ?? outputFile.path
|
||||
)
|
||||
const format = resolveOutputFormat(fileName, outputFile.format)
|
||||
const content = serializeOutputForFile(result.output, format)
|
||||
const contentType = outputFile.mimeType || FORMAT_TO_CONTENT_TYPE[format]
|
||||
const buffer = Buffer.from(content, 'utf-8')
|
||||
|
||||
if (context.abortSignal?.aborted) {
|
||||
throw new Error('Request aborted before tool mutation could be applied')
|
||||
}
|
||||
|
||||
const written = await writeWorkspaceFileByPath({
|
||||
workspaceId: context.workspaceId!,
|
||||
userId: context.userId!,
|
||||
target: {
|
||||
path: outputFile.path,
|
||||
mode: outputFile.mode ?? 'create',
|
||||
mimeType: outputFile.mimeType,
|
||||
},
|
||||
buffer,
|
||||
inferredMimeType: contentType,
|
||||
})
|
||||
writtenFiles.push({
|
||||
...written,
|
||||
bytes: buffer.length,
|
||||
format,
|
||||
requestedPath: outputFile.path,
|
||||
})
|
||||
}
|
||||
|
||||
const firstWritten = writtenFiles[0]
|
||||
span.setAttributes({
|
||||
[TraceAttr.CopilotOutputFileId]: firstWritten.id,
|
||||
[TraceAttr.CopilotOutputFileName]: firstWritten.name,
|
||||
[TraceAttr.CopilotOutputFileFormat]: firstWritten.format,
|
||||
[TraceAttr.CopilotOutputFilePath]: firstWritten.vfsPath,
|
||||
[TraceAttr.CopilotOutputFileMode]: firstWritten.mode,
|
||||
[TraceAttr.CopilotOutputFileBytes]: firstWritten.bytes,
|
||||
[TraceAttr.CopilotOutputFileOutcome]: CopilotOutputFileOutcome.Uploaded,
|
||||
})
|
||||
|
||||
logger.info('Tool output written to file', {
|
||||
toolName,
|
||||
outputCount: writtenFiles.length,
|
||||
files: writtenFiles.map((file) => ({
|
||||
fileId: file.id,
|
||||
vfsPath: file.vfsPath,
|
||||
size: file.bytes,
|
||||
})),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message:
|
||||
writtenFiles.length === 1
|
||||
? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)`
|
||||
: `Output written to ${writtenFiles.length} files`,
|
||||
files: writtenFiles.map((file) => ({
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
vfsPath: file.vfsPath,
|
||||
size: file.bytes,
|
||||
downloadUrl: file.downloadUrl,
|
||||
})),
|
||||
fileId: firstWritten.id,
|
||||
fileName: firstWritten.name,
|
||||
vfsPath: firstWritten.vfsPath,
|
||||
size: firstWritten.bytes,
|
||||
downloadUrl: firstWritten.downloadUrl,
|
||||
},
|
||||
resources: writtenFiles.map((file) => ({
|
||||
type: 'file',
|
||||
id: file.id,
|
||||
title: file.name,
|
||||
path: file.vfsPath,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
const message = toError(err).message
|
||||
logger.warn('Failed to write tool output to file', {
|
||||
toolName,
|
||||
outputPaths: outputFiles.map((file) => file.path),
|
||||
error: message,
|
||||
})
|
||||
span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed)
|
||||
span.addEvent(TraceEvent.CopilotOutputFileError, {
|
||||
[TraceAttr.ErrorMessage]: message.slice(0, 500),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to write output file: ${message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
|
||||
/**
|
||||
* Guards a post-tool output-redirection sink against read-only principals.
|
||||
*
|
||||
* `function_execute`, `user_table`, and `read` are read-allowed for execution
|
||||
* (they don't mutate the workspace themselves), so the router's `WRITE_ACTIONS`
|
||||
* gate in `tools/server/router.ts` lets read-only collaborators run them. But
|
||||
* their output-redirection declarations (`outputs.files`, `outputTable`)
|
||||
* durably persist to the workspace — creating/overwriting files and table rows.
|
||||
* Those writes must satisfy the same write gate as the dedicated mutation tools.
|
||||
*
|
||||
* Returns a denial `ToolCallResult` when the caller lacks write access (so the
|
||||
* agent surfaces the same `Permission denied` outcome it gets from `create_file`
|
||||
* / `user_table` writes), or `null` when the write may proceed.
|
||||
*/
|
||||
export function denyOutputWriteWithoutWritePermission(
|
||||
context: ExecutionContext
|
||||
): ToolCallResult | null {
|
||||
if (permissionSatisfies(context.userPermission as PermissionType | undefined, 'write')) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Permission denied: writing tool output to the workspace requires write access. You have '${context.userPermission ?? 'none'}' permission.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1ResourceOp,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
||||
import type { StreamEvent, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import {
|
||||
extractDeletedResourcesFromToolResult,
|
||||
extractResourcesFromToolResult,
|
||||
hasDeleteCapability,
|
||||
isResourceToolName,
|
||||
persistChatResources,
|
||||
removeChatResources,
|
||||
} from '@/lib/copilot/resources/persistence'
|
||||
|
||||
const logger = createLogger('CopilotResourceEffects')
|
||||
|
||||
/**
|
||||
* Persist and emit resource events after a successful tool execution.
|
||||
*
|
||||
* Handles both creation/upsert and deletion of chat resources depending on
|
||||
* the tool's capabilities and output shape.
|
||||
*/
|
||||
export async function handleResourceSideEffects(
|
||||
toolName: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
result: ToolCallResult,
|
||||
chatId: string,
|
||||
onEvent: ((event: StreamEvent) => void | Promise<void>) | undefined,
|
||||
isAborted: () => boolean
|
||||
): Promise<void> {
|
||||
// Cheap early exit so we don't emit a span for tools that can never
|
||||
// produce resources (most of them). The span only shows up for tools
|
||||
// that might actually do resource work.
|
||||
if (
|
||||
!hasDeleteCapability(toolName) &&
|
||||
!isResourceToolName(toolName) &&
|
||||
!(result.resources && result.resources.length > 0)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotToolsHandleResourceSideEffects,
|
||||
{
|
||||
[TraceAttr.ToolName]: toolName,
|
||||
[TraceAttr.ChatId]: chatId,
|
||||
},
|
||||
async (span) => {
|
||||
let isDeleteOp = false
|
||||
let removedCount = 0
|
||||
let upsertedCount = 0
|
||||
|
||||
if (hasDeleteCapability(toolName)) {
|
||||
const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output)
|
||||
if (deleted.length > 0) {
|
||||
isDeleteOp = true
|
||||
removedCount = deleted.length
|
||||
// Detached from the span lifecycle — the span ends before the
|
||||
// DB call completes. That is intentional; we want the span to
|
||||
// reflect the synchronous decision + event emission, not the
|
||||
// best-effort persistence.
|
||||
removeChatResources(chatId, deleted).catch((err) => {
|
||||
logger.warn('Failed to remove chat resources after deletion', {
|
||||
chatId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
|
||||
for (const resource of deleted) {
|
||||
if (isAborted()) break
|
||||
await onEvent?.({
|
||||
type: MothershipStreamV1EventType.resource,
|
||||
payload: {
|
||||
op: MothershipStreamV1ResourceOp.remove,
|
||||
resource: { type: resource.type, id: resource.id, title: resource.title },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDeleteOp && !isAborted()) {
|
||||
const resources =
|
||||
result.resources && result.resources.length > 0
|
||||
? result.resources
|
||||
: isResourceToolName(toolName)
|
||||
? extractResourcesFromToolResult(toolName, params, result.output)
|
||||
: []
|
||||
|
||||
if (resources.length > 0) {
|
||||
upsertedCount = resources.length
|
||||
logger.info('[file-stream-server] Emitting resource upsert events', {
|
||||
toolName,
|
||||
chatId,
|
||||
resources: resources.map((r) => ({ type: r.type, id: r.id, title: r.title })),
|
||||
})
|
||||
persistChatResources(chatId, resources).catch((err) => {
|
||||
logger.warn('Failed to persist chat resources', {
|
||||
chatId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
})
|
||||
|
||||
for (const resource of resources) {
|
||||
if (isAborted()) break
|
||||
await onEvent?.({
|
||||
type: MothershipStreamV1EventType.resource,
|
||||
payload: {
|
||||
op: MothershipStreamV1ResourceOp.upsert,
|
||||
resource: { type: resource.type, id: resource.id, title: resource.title },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
[TraceAttr.CopilotResourcesOp]: isDeleteOp
|
||||
? 'delete'
|
||||
: upsertedCount > 0
|
||||
? 'upsert'
|
||||
: 'none',
|
||||
[TraceAttr.CopilotResourcesRemovedCount]: removedCount,
|
||||
[TraceAttr.CopilotResourcesUpsertedCount]: upsertedCount,
|
||||
[TraceAttr.CopilotResourcesAborted]: isAborted(),
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
|
||||
const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({
|
||||
mockGetTableById: vi.fn(),
|
||||
mockReplaceTableRows: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/service', () => ({
|
||||
getTableById: mockGetTableById,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/rows/service', () => ({
|
||||
replaceTableRows: mockReplaceTableRows,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/otel', () => ({
|
||||
withCopilotSpan: (
|
||||
_name: string,
|
||||
_attrs: Record<string, unknown> | undefined,
|
||||
fn: (span: unknown) => Promise<unknown>
|
||||
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }),
|
||||
}))
|
||||
|
||||
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import {
|
||||
maybeWriteOutputToTable,
|
||||
maybeWriteReadCsvToTable,
|
||||
} from '@/lib/copilot/request/tools/tables'
|
||||
import type { ExecutionContext } from '@/lib/copilot/request/types'
|
||||
|
||||
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
|
||||
return {
|
||||
id: 'tbl_1',
|
||||
name: 'People',
|
||||
description: null,
|
||||
schema: {
|
||||
columns: [
|
||||
{ id: 'col_name', name: 'name', type: 'string' },
|
||||
{ id: 'col_age', name: 'age', type: 'number' },
|
||||
],
|
||||
},
|
||||
metadata: null,
|
||||
rowCount: 0,
|
||||
maxRows: 100,
|
||||
workspaceId: 'workspace-1',
|
||||
createdBy: 'user-1',
|
||||
archivedAt: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
...overrides,
|
||||
} as TableDefinition
|
||||
}
|
||||
|
||||
function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
workspaceId: 'workspace-1',
|
||||
userPermission: 'write',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('maybeWriteOutputToTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetTableById.mockResolvedValue(buildTable())
|
||||
mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 })
|
||||
})
|
||||
|
||||
it('rejects a table from another workspace without touching it', async () => {
|
||||
mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' }))
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ name: 'Alice' }] } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' })
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies a read-only principal without touching the table', async () => {
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ name: 'Alice' }] } },
|
||||
buildContext({ userPermission: 'read' })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('requires write access')
|
||||
expect(mockGetTableById).not.toHaveBeenCalled()
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces rows through the service with name keys remapped to column ids', async () => {
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{
|
||||
success: true,
|
||||
output: {
|
||||
result: [
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 40 },
|
||||
],
|
||||
},
|
||||
},
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockReplaceTableRows).toHaveBeenCalledTimes(1)
|
||||
const [data, table] = mockReplaceTableRows.mock.calls[0]
|
||||
expect(data).toMatchObject({
|
||||
tableId: 'tbl_1',
|
||||
workspaceId: 'workspace-1',
|
||||
userId: 'user-1',
|
||||
rows: [
|
||||
{ col_name: 'Alice', col_age: 30 },
|
||||
{ col_name: 'Bob', col_age: 40 },
|
||||
],
|
||||
})
|
||||
expect(table.id).toBe('tbl_1')
|
||||
})
|
||||
|
||||
it('fails fast when no row keys match the table columns', async () => {
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ wrong: 1 }, { keys: 2 }] } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Row 1 has no keys matching columns')
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails fast when only some rows match instead of writing empty rows', async () => {
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ name: 'Alice' }, { wrong: 'x' }] } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Row 2 has no keys matching columns')
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces service validation failures as tool errors', async () => {
|
||||
mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required'))
|
||||
|
||||
const result = await maybeWriteOutputToTable(
|
||||
FunctionExecute.id,
|
||||
{ outputTable: 'tbl_1' },
|
||||
{ success: true, output: { result: [{ age: 30 }] } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Row 1: name is required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('maybeWriteReadCsvToTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetTableById.mockResolvedValue(buildTable())
|
||||
mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 })
|
||||
})
|
||||
|
||||
it('rejects a table from another workspace without touching it', async () => {
|
||||
mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' }))
|
||||
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
{ success: true, output: { content: 'name,age\nAlice,30' } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' })
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies a read-only principal without touching the table', async () => {
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
{ success: true, output: { content: 'name,age\nAlice,30' } },
|
||||
buildContext({ userPermission: 'read' })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('requires write access')
|
||||
expect(mockGetTableById).not.toHaveBeenCalled()
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('imports CSV content through the service with id-keyed rows', async () => {
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
{ success: true, output: { content: 'name,age\nAlice,30\nBob,40' } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const [data] = mockReplaceTableRows.mock.calls[0]
|
||||
expect(data.rows).toEqual([
|
||||
{ col_name: 'Alice', col_age: '30' },
|
||||
{ col_name: 'Bob', col_age: '40' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fails fast when the file headers match no table columns', async () => {
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
{ success: true, output: { content: 'wrong,headers\n1,2' } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Row 1 has no keys matching columns')
|
||||
expect(mockReplaceTableRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces service validation failures as tool errors', async () => {
|
||||
mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required'))
|
||||
|
||||
const result = await maybeWriteReadCsvToTable(
|
||||
ReadTool.id,
|
||||
{ outputTable: 'tbl_1', path: 'files/people.csv' },
|
||||
{ success: true, output: { content: 'age\n30' } },
|
||||
buildContext()
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Row 1: name is required')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { parse as csvParse } from 'csv-parse/sync'
|
||||
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
||||
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import type { RowData, TableDefinition } from '@/lib/table'
|
||||
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
|
||||
import { replaceTableRows } from '@/lib/table/rows/service'
|
||||
import { getTableById } from '@/lib/table/service'
|
||||
|
||||
const logger = createLogger('CopilotToolResultTables')
|
||||
|
||||
const MAX_OUTPUT_TABLE_ROWS = 10_000
|
||||
|
||||
/**
|
||||
* Replaces a table's rows with wire rows keyed by column name. Translates the
|
||||
* keys to stable column ids (unknown keys are dropped, matching every other
|
||||
* name-translating boundary) and delegates to `replaceTableRows`, which owns
|
||||
* locking, validation, plan row limits, batching, and rowCount maintenance.
|
||||
*/
|
||||
async function replaceTableRowsFromWire(
|
||||
table: TableDefinition,
|
||||
rows: Array<Record<string, unknown>>,
|
||||
context: ExecutionContext
|
||||
): Promise<{ error?: string }> {
|
||||
const idByName = buildIdByName(table.schema)
|
||||
const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName))
|
||||
const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0)
|
||||
if (emptyIndex !== -1) {
|
||||
return {
|
||||
error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`,
|
||||
}
|
||||
}
|
||||
await replaceTableRows(
|
||||
{
|
||||
tableId: table.id,
|
||||
rows: idKeyedRows,
|
||||
workspaceId: table.workspaceId,
|
||||
userId: context.userId,
|
||||
},
|
||||
table,
|
||||
generateId().slice(0, 8)
|
||||
)
|
||||
return {}
|
||||
}
|
||||
|
||||
export async function maybeWriteOutputToTable(
|
||||
toolName: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
result: ToolCallResult,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
if (toolName !== FunctionExecute.id) return result
|
||||
if (!result.success || !result.output) return result
|
||||
if (!context.workspaceId || !context.userId) return result
|
||||
|
||||
const outputTable = params?.outputTable as string | undefined
|
||||
if (!outputTable) return result
|
||||
|
||||
const denied = denyOutputWriteWithoutWritePermission(context)
|
||||
if (denied) return denied
|
||||
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotToolsWriteOutputTable,
|
||||
{
|
||||
[TraceAttr.ToolName]: toolName,
|
||||
[TraceAttr.CopilotTableId]: outputTable,
|
||||
[TraceAttr.WorkspaceId]: context.workspaceId,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const table = await getTableById(outputTable)
|
||||
if (!table || table.workspaceId !== context.workspaceId) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
|
||||
return {
|
||||
success: false,
|
||||
error: `Table "${outputTable}" not found`,
|
||||
}
|
||||
}
|
||||
|
||||
const rawOutput = result.output
|
||||
let rows: Array<Record<string, unknown>>
|
||||
|
||||
if (rawOutput && typeof rawOutput === 'object' && 'result' in rawOutput) {
|
||||
const inner = (rawOutput as Record<string, unknown>).result
|
||||
if (Array.isArray(inner)) {
|
||||
rows = inner
|
||||
} else {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
||||
return {
|
||||
success: false,
|
||||
error: 'outputTable requires the code to return an array of objects',
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(rawOutput)) {
|
||||
rows = rawOutput
|
||||
} else {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
||||
return {
|
||||
success: false,
|
||||
error: 'outputTable requires the code to return an array of objects',
|
||||
}
|
||||
}
|
||||
|
||||
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
|
||||
|
||||
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
|
||||
return {
|
||||
success: false,
|
||||
error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
|
||||
return {
|
||||
success: false,
|
||||
error: 'outputTable requires at least one row — code returned an empty array',
|
||||
}
|
||||
}
|
||||
|
||||
if (context.abortSignal?.aborted) {
|
||||
throw new Error('Request aborted before tool mutation could be applied')
|
||||
}
|
||||
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
|
||||
if (replaceResult.error) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
||||
return { success: false, error: replaceResult.error }
|
||||
}
|
||||
|
||||
logger.info('Tool output written to table', {
|
||||
toolName,
|
||||
tableId: outputTable,
|
||||
rowCount: rows.length,
|
||||
})
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Wrote)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Wrote ${rows.length} rows to table ${outputTable}`,
|
||||
tableId: outputTable,
|
||||
rowCount: rows.length,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to write tool output to table', {
|
||||
toolName,
|
||||
outputTable,
|
||||
error: toError(err).message,
|
||||
})
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
|
||||
span.addEvent(TraceEvent.CopilotTableError, {
|
||||
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to write to table: ${toError(err).message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function maybeWriteReadCsvToTable(
|
||||
toolName: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
result: ToolCallResult,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
if (toolName !== ReadTool.id) return result
|
||||
if (!result.success || !result.output) return result
|
||||
if (!context.workspaceId || !context.userId) return result
|
||||
|
||||
const outputTable = params?.outputTable as string | undefined
|
||||
if (!outputTable) return result
|
||||
|
||||
const denied = denyOutputWriteWithoutWritePermission(context)
|
||||
if (denied) return denied
|
||||
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotToolsWriteCsvToTable,
|
||||
{
|
||||
[TraceAttr.ToolName]: toolName,
|
||||
[TraceAttr.CopilotTableId]: outputTable,
|
||||
[TraceAttr.WorkspaceId]: context.workspaceId,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const table = await getTableById(outputTable)
|
||||
if (!table || table.workspaceId !== context.workspaceId) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
|
||||
return { success: false, error: `Table "${outputTable}" not found` }
|
||||
}
|
||||
|
||||
const output = result.output as Record<string, unknown>
|
||||
const content = (output.content as string) || ''
|
||||
if (!content.trim()) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyContent)
|
||||
return { success: false, error: 'File has no content to import into table' }
|
||||
}
|
||||
|
||||
const filePath = (params?.path as string) || ''
|
||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||
span.setAttributes({
|
||||
[TraceAttr.CopilotTableSourcePath]: filePath,
|
||||
[TraceAttr.CopilotTableSourceFormat]: ext === 'json' ? 'json' : 'csv',
|
||||
[TraceAttr.CopilotTableSourceContentBytes]: content.length,
|
||||
})
|
||||
|
||||
let rows: Record<string, unknown>[]
|
||||
|
||||
if (ext === 'json') {
|
||||
const parsed = JSON.parse(content)
|
||||
if (!Array.isArray(parsed)) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidJsonShape)
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON file must contain an array of objects for table import',
|
||||
}
|
||||
}
|
||||
rows = parsed
|
||||
} else {
|
||||
rows = csvParse(content, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
relax_column_count: true,
|
||||
relax_quotes: true,
|
||||
skip_records_with_error: true,
|
||||
cast: false,
|
||||
}) as Record<string, unknown>[]
|
||||
}
|
||||
|
||||
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
|
||||
|
||||
if (rows.length === 0) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
|
||||
return { success: false, error: 'File has no data rows to import' }
|
||||
}
|
||||
|
||||
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
|
||||
return {
|
||||
success: false,
|
||||
error: `Row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (context.abortSignal?.aborted) {
|
||||
throw new Error('Request aborted before tool mutation could be applied')
|
||||
}
|
||||
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
|
||||
if (replaceResult.error) {
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
||||
return { success: false, error: replaceResult.error }
|
||||
}
|
||||
|
||||
logger.info('Read output written to table', {
|
||||
toolName,
|
||||
tableId: outputTable,
|
||||
tableName: table.name,
|
||||
rowCount: rows.length,
|
||||
filePath,
|
||||
})
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Imported)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`,
|
||||
tableId: outputTable,
|
||||
tableName: table.name,
|
||||
rowCount: rows.length,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to write read output to table', {
|
||||
toolName,
|
||||
outputTable,
|
||||
error: toError(err).message,
|
||||
})
|
||||
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
|
||||
span.addEvent(TraceEvent.CopilotTableError, {
|
||||
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to import into table: ${toError(err).message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user