Fix discovery tool

This commit is contained in:
Siddharth Ganesan
2026-02-07 11:37:26 -08:00
parent 2a7ebfb396
commit 38e2aa0efa
7 changed files with 99 additions and 39 deletions

View File

@@ -18,7 +18,11 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import {
ORCHESTRATION_TIMEOUT_MS,
SIM_AGENT_API_URL,
SIM_AGENT_VERSION,
} from '@/lib/copilot/constants'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { env } from '@/lib/core/config/env'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
@@ -35,6 +39,7 @@ const mcpRateLimiter = new RateLimiter()
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 300
interface CopilotKeyAuthResult {
success: boolean
@@ -358,7 +363,7 @@ class NextResponseCapture {
}
}
function buildMcpServer(): Server {
function buildMcpServer(abortSignal?: AbortSignal): Server {
const server = new Server(
{
name: 'sim-copilot',
@@ -449,7 +454,8 @@ function buildMcpServer(): Server {
name: params.name,
arguments: params.arguments,
},
authResult.userId
authResult.userId,
abortSignal
)
trackMcpCopilotCall(authResult.userId)
@@ -464,7 +470,7 @@ async function handleMcpRequestWithSdk(
request: NextRequest,
parsedBody: unknown
): Promise<NextResponse> {
const server = buildMcpServer()
const server = buildMcpServer(request.signal)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
@@ -481,7 +487,10 @@ async function handleMcpRequestWithSdk(
try {
await transport.handleRequest(requestAdapter as any, responseCapture as any, parsedBody)
await responseCapture.waitForHeaders()
await responseCapture.waitForEnd()
// Must exceed the longest possible tool execution (build = 5 min).
// Using ORCHESTRATION_TIMEOUT_MS + 60 s buffer so the orchestrator can
// finish or time-out on its own before the transport is torn down.
await responseCapture.waitForEnd(ORCHESTRATION_TIMEOUT_MS + 60_000)
return responseCapture.toNextResponse()
} finally {
await server.close().catch(() => {})
@@ -540,7 +549,8 @@ function trackMcpCopilotCall(userId: string): void {
async function handleToolsCall(
params: { name: string; arguments?: Record<string, unknown> },
userId: string
userId: string,
abortSignal?: AbortSignal
): Promise<CallToolResult> {
const args = params.arguments || {}
@@ -551,7 +561,7 @@ async function handleToolsCall(
const subagentTool = SUBAGENT_TOOL_DEFS.find((tool) => tool.name === params.name)
if (subagentTool) {
return handleSubagentToolCall(subagentTool, args, userId)
return handleSubagentToolCall(subagentTool, args, userId, abortSignal)
}
throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${params.name}`)
@@ -606,7 +616,8 @@ async function handleDirectToolCall(
*/
async function handleBuildToolCall(
args: Record<string, unknown>,
userId: string
userId: string,
abortSignal?: AbortSignal
): Promise<CallToolResult> {
try {
const requestText = (args.request as string) || JSON.stringify(args)
@@ -657,6 +668,7 @@ async function handleBuildToolCall(
autoExecuteTools: true,
timeout: 300000,
interactive: false,
abortSignal,
})
const responseData = {
@@ -687,10 +699,11 @@ async function handleBuildToolCall(
async function handleSubagentToolCall(
toolDef: (typeof SUBAGENT_TOOL_DEFS)[number],
args: Record<string, unknown>,
userId: string
userId: string,
abortSignal?: AbortSignal
): Promise<CallToolResult> {
if (toolDef.agentId === 'build') {
return handleBuildToolCall(args, userId)
return handleBuildToolCall(args, userId, abortSignal)
}
try {
@@ -722,6 +735,7 @@ async function handleSubagentToolCall(
userId,
workflowId: args.workflowId as string | undefined,
workspaceId: args.workspaceId as string | undefined,
abortSignal,
}
)

View File

@@ -1444,13 +1444,7 @@ export function ToolCall({
toolCall.name === 'mark_todo_in_progress' ||
toolCall.name === 'tool_search_tool_regex' ||
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond' ||
toolCall.name === 'research_respond' ||
toolCall.name === 'info_respond' ||
toolCall.name === 'deploy_respond' ||
toolCall.name === 'superagent_respond'
toolCall.name.endsWith('_respond')
)
return null

View File

@@ -109,6 +109,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
const toolData = getEventData(event) || ({} as Record<string, unknown>)
const toolCallId = (toolData.id as string | undefined) || event.toolCallId
const toolName = (toolData.name as string | undefined) || event.toolName
if (!toolCallId || !toolName) return
const args = (toolData.arguments || toolData.input || asRecord(event.data).input) as

View File

@@ -62,13 +62,25 @@ export async function executeToolAndReport(
markToolResultSeen(toolCall.id)
await markToolComplete(
// Fire-and-forget: notify the copilot backend that the tool completed.
// IMPORTANT: We must NOT await this — the Go backend may block on the
// mark-complete handler until it can write back on the SSE stream, but
// the SSE reader (our for-await loop) is paused while we're in this
// handler. Awaiting here would deadlock: sim waits for Go's response,
// Go waits for sim to drain the SSE stream.
markToolComplete(
toolCall.id,
toolCall.name,
result.success ? 200 : 500,
result.error || (result.success ? 'Tool completed' : 'Tool failed'),
result.output
)
).catch((err) => {
logger.error('markToolComplete fire-and-forget failed', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: err instanceof Error ? err.message : String(err),
})
})
const resultEvent: SSEEvent = {
type: 'tool_result',
@@ -91,7 +103,14 @@ export async function executeToolAndReport(
markToolResultSeen(toolCall.id)
await markToolComplete(toolCall.id, toolCall.name, 500, toolCall.error)
// Fire-and-forget (same reasoning as above).
markToolComplete(toolCall.id, toolCall.name, 500, toolCall.error).catch((err) => {
logger.error('markToolComplete fire-and-forget failed', {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: err instanceof Error ? err.message : String(err),
})
})
const errorEvent: SSEEvent = {
type: 'tool_error',

View File

@@ -206,6 +206,9 @@ async function executeSimWorkflowTool(
return handler(params, context)
}
/** Timeout for the mark-complete POST to the copilot backend (30 s). */
const MARK_COMPLETE_TIMEOUT_MS = 30_000
/**
* Notify the copilot backend that a tool has completed.
*/
@@ -217,30 +220,42 @@ export async function markToolComplete(
data?: unknown
): Promise<boolean> {
try {
const response = await fetch(`${SIM_AGENT_API_URL}/api/tools/mark-complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({
id: toolCallId,
name: toolName,
status,
message,
data,
}),
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), MARK_COMPLETE_TIMEOUT_MS)
if (!response.ok) {
logger.warn('Mark-complete call failed', { toolCallId, status: response.status })
return false
try {
const response = await fetch(`${SIM_AGENT_API_URL}/api/tools/mark-complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({
id: toolCallId,
name: toolName,
status,
message,
data,
}),
signal: controller.signal,
})
if (!response.ok) {
logger.warn('Mark-complete call failed', { toolCallId, toolName, status: response.status })
return false
}
return true
} finally {
clearTimeout(timeoutId)
}
return true
} catch (error) {
const isTimeout =
error instanceof DOMException && error.name === 'AbortError'
logger.error('Mark-complete call failed', {
toolCallId,
toolName,
timedOut: isTimeout,
error: error instanceof Error ? error.message : String(error),
})
return false

View File

@@ -13,6 +13,9 @@ type StoreSet = (
partial: Partial<CopilotStore> | ((state: CopilotStore) => Partial<CopilotStore>)
) => void
/** Respond tools are internal to copilot subagents and should never be shown in the UI */
const HIDDEN_TOOL_SUFFIX = '_respond'
export function resolveToolDisplay(
toolName: string | undefined,
state: ClientToolCallState,
@@ -20,6 +23,7 @@ export function resolveToolDisplay(
params?: Record<string, any>
): ClientToolDisplay | undefined {
if (!toolName) return undefined
if (toolName.endsWith(HIDDEN_TOOL_SUFFIX)) return undefined
const entry = TOOL_DISPLAY_REGISTRY[toolName]
if (!entry) return humanizedFallback(toolName, state)

View File

@@ -862,6 +862,18 @@ const META_get_operations_examples: ToolMetadata = {
},
}
const META_get_platform_actions: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Viewing platform actions', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Viewing platform actions', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Viewing platform actions', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Viewed platform actions', icon: Navigation },
[ClientToolCallState.error]: { text: 'Failed to view platform actions', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped platform actions', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted platform actions', icon: MinusCircle },
},
}
const META_get_page_contents: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 },
@@ -2259,6 +2271,7 @@ const TOOL_METADATA_BY_ID: Record<string, ToolMetadata> = {
get_examples_rag: META_get_examples_rag,
get_operations_examples: META_get_operations_examples,
get_page_contents: META_get_page_contents,
get_platform_actions: META_get_platform_actions,
get_trigger_blocks: META_get_trigger_blocks,
get_trigger_examples: META_get_trigger_examples,
get_user_workflow: META_get_user_workflow,