src/services/assistantAgent.ts
Rama main · réplica de lectura del repositorio original.
import type { AssistantAction, AssistantCard, AssistantContext, AssistantResponse, AssistantTurn } from '~/services/assistant'
import { assistantOpenAIRequest, assistantRequest } from '~/services/assistantHttp'
import { defaultApiHost } from '~/services/console'
/**
* The console assistant runs locally; browser model requests pass through the authenticated Capgo
* proxy, while native requests go directly to OpenAI. Tools use the hosted Capgo MCP server with
* the user's assistant API key and the normal public API rules (RBAC, rate limits).
*/
/** Preferred model; ChatGPT plans list their own catalog, so the first listed model is the fallback. */
export const PREFERRED_MODEL = 'gpt-6.1-sol'
const MAX_ROUNDS = 8
const MAX_TOOL_RESULT_CHARS = 20_000
const MODEL_TIMEOUT_MS = 90_000
const MCP_TIMEOUT_MS = 60_000
export class AssistantUpstreamError extends Error {
constructor(readonly code: 'chatgpt_usage_limit' | 'chatgpt_not_eligible' | 'chatgpt_reconnect_required' | 'assistant_upstream_error' | 'assistant_key_invalid' | 'assistant_session_required') {
super(code)
}
}
export interface McpToolInfo {
name: string
title?: string
description: string
inputSchema: Record<string, unknown>
annotations?: { title?: string, destructiveHint?: boolean, openWorldHint?: boolean }
}
// ---------------------------------------------------------------------------
// Capgo MCP (JSON-RPC over the hosted /mcp endpoint, stateless)
// ---------------------------------------------------------------------------
let rpcId = 0
const toolsCache = new Map<string, McpToolInfo[]>()
async function mcpRpc(apikey: string, method: string, params: Record<string, unknown>): Promise<unknown> {
const response = await assistantRequest(`${defaultApiHost}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', 'authorization': `Bearer ${apikey}` },
body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }),
timeoutMs: MCP_TIMEOUT_MS,
})
if (response.status === 401)
throw new AssistantUpstreamError('assistant_key_invalid')
const body = JSON.parse(response.text) as { result?: unknown, error?: { message?: string } }
if (body.error)
throw new Error(body.error.message ?? 'mcp_error')
return body.result
}
export async function listMcpTools(apikey: string): Promise<McpToolInfo[]> {
const cached = toolsCache.get(apikey)
if (cached)
return cached
const result = await mcpRpc(apikey, 'tools/list', {}) as { tools?: McpToolInfo[] }
const tools = result.tools ?? []
toolsCache.set(apikey, tools)
return tools
}
async function callMcpTool(apikey: string, name: string, args: Record<string, unknown>): Promise<{ ok: boolean, text: string }> {
try {
const result = await mcpRpc(apikey, 'tools/call', { name, arguments: args }) as { content?: Array<{ text?: string }>, isError?: boolean }
return { ok: !result.isError, text: (result.content ?? []).map(part => part.text ?? '').join('\n') }
}
catch (error) {
if (error instanceof AssistantUpstreamError)
throw error
return { ok: false, text: `Tool failed: ${error instanceof Error ? error.message : String(error)}` }
}
}
/** Deletes, overwrites, cancellations and push sends wait for an explicit click in the UI. */
export function needsConfirmation(tool: McpToolInfo): boolean {
return tool.annotations?.destructiveHint === true || tool.annotations?.openWorldHint === true
}
// ---------------------------------------------------------------------------
// Prompt and tools
// ---------------------------------------------------------------------------
// Console routes the model may link to. Ids in braces must come from MCP tool results.
const CONSOLE_ROUTES = `
/dashboard - organization overview (usage, apps, charts)
/apps - list of apps in the current organization
/app/new - add a new app
/app/{appId} - app overview dashboard
/app/{appId}/getting-started - app onboarding steps
/app/{appId}/bundles - bundle list; /app/{appId}/bundles/new - upload a bundle
/app/{appId}/bundle/{bundleId} - one bundle (bundleId is the numeric id, not the version name); suffixes /history, /manifest, /dependencies, /preview
/app/{appId}/channels - channel list
/app/{appId}/channel/{channelId} - one channel (numeric id); suffixes /devices, /history, /statistics, /preview
/app/{appId}/devices - device list; /app/{appId}/device/{deviceId} - one device; suffixes /logs, /deployments
/app/{appId}/builds - native builds
/app/{appId}/notifications - push notifications
/app/{appId}/observe/errors, /observe/logs, /observe/native, /observe/compatibility - Observe health data
/app/{appId}/settings - app settings; /settings/access - app access; /settings/usage - app usage
/settings/account - profile; /settings/account/change-password; /settings/account/notifications
/settings/organization - organization settings; suffixes /members, /groups, /security, /api-keys, /webhooks, /audit-logs, /usage, /plans, /credits, /notifications
`.trim()
const UI_FUNCTIONS = [
{
type: 'function',
name: 'show_link',
description: 'Show the user a clickable card that opens a page of the Capgo console. Use it whenever you mention where something lives, after finding a resource, or after an action so the user can check the result.',
strict: false,
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Short card title, e.g. "Production channel"' },
description: { type: 'string', description: 'One short line about what the page shows' },
path: { type: 'string', description: 'Console path starting with /, built from the route list and real ids' },
},
required: ['title', 'path'],
additionalProperties: false,
},
},
{
type: 'function',
name: 'ask_user',
description: 'Ask the user a question with clickable answers. Use it instead of a plain-text question whenever a choice is needed (which app, which channel, which bundle, yes/no). Fill options from real data you fetched. Stop and wait for the answer after calling it.',
strict: false,
parameters: {
type: 'object',
properties: {
question: { type: 'string' },
options: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8, description: 'Answers shown as buttons' },
allow_free_text: { type: 'boolean', description: 'Also let the user type another answer' },
},
required: ['question', 'options'],
additionalProperties: false,
},
},
]
/** ChatGPT plan usage only accepts function tools grouped in namespaces. */
export function responsesTools(mcpTools: McpToolInfo[]): unknown[] {
return [
{ type: 'namespace', name: 'capgo_ui', description: 'Show cards in the Capgo chat panel.', tools: UI_FUNCTIONS },
{
type: 'namespace',
name: 'capgo',
description: 'Capgo Cloud MCP tools acting on the user account.',
tools: mcpTools.map(tool => ({ type: 'function', name: tool.name, description: tool.description, strict: false, parameters: tool.inputSchema })),
},
]
}
function instructions(context: AssistantContext): string {
const where = [
context.path ? `Current page: ${context.path}` : null,
context.orgId ? `Current organization: ${context.orgName ?? ''} (${context.orgId})` : null,
context.appId ? `Current app: ${context.appId}` : null,
].filter(Boolean).join('\n')
return [
'You are the Capgo assistant inside the Capgo web console. You help users find things and act on their Capgo account (Capacitor live updates: apps, bundles, channels, devices, stats, builds, webhooks, notifications).',
'Rules:',
'- Use the capgo tools to look up real data before answering. Never invent ids, versions or numbers. Start with capgo_list_apps when you need an app.',
'- "This app", "here" and similar refer to the current page context below.',
'- When the user asks where something is, or after you find or change a resource, call show_link with the matching console page.',
'- When you need the user to pick something, call ask_user with options from real data instead of asking in plain text.',
'- Delete, overwrite, cancel and push-notification tools are not run immediately: the user gets a confirm card. Tell them to confirm it.',
'- Uploading bundle files and starting native builds need the Capgo CLI: give the command (bunx @capgo/cli@latest ...).',
'- Reply in the user\'s language, short and plain (a few sentences, simple markdown lists at most).',
'',
`Console routes:\n${CONSOLE_ROUTES}`,
where ? `\n${where}` : '',
].join('\n')
}
/** Only same-origin console paths: no scheme, no protocol-relative or backslash tricks. */
export function sanitizeConsolePath(path: unknown): string | null {
if (typeof path !== 'string')
return null
const trimmed = path.trim()
if (!trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('\\') || /\s/.test(trimmed) || trimmed.length > 500)
return null
return trimmed
}
function clip(text: string, max = MAX_TOOL_RESULT_CHARS): string {
return text.length > max ? `${text.slice(0, max)}\n… (truncated)` : text
}
function parseArguments(raw: string): Record<string, unknown> {
try {
const value = JSON.parse(raw || '{}')
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
catch {
return {}
}
}
// ---------------------------------------------------------------------------
// OpenAI Responses API
// ---------------------------------------------------------------------------
interface FunctionCallItem {
type: 'function_call'
call_id: string
name: string
arguments: string
namespace?: string
}
type InputItem
= | { role: 'user' | 'assistant' | 'developer', content: string }
| FunctionCallItem
| { type: 'function_call_output', call_id: string, output: string }
interface OutputItem {
type: string
content?: Array<{ type: string, text?: string }>
call_id?: string
name?: string
arguments?: string
namespace?: string
}
export interface ModelTurn {
text: string
calls: FunctionCallItem[]
failedCode?: string
}
/**
* Parse a Responses API event stream. With store: false the completed event can carry an empty
* output, so items announced by response.output_item.done are kept as the fallback.
*/
export function parseResponseStream(text: string): ModelTurn {
let completed = false
let output: OutputItem[] = []
const streamedItems: OutputItem[] = []
let streamedText = ''
let failedCode: string | undefined
for (const block of text.split(/\r?\n\r?\n/)) {
const data = block.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n')
if (!data || data === '[DONE]')
continue
let event: { type?: string, delta?: string, item?: OutputItem, response?: { output?: OutputItem[], error?: { code?: string } }, error?: { code?: string }, code?: string }
try {
event = JSON.parse(data)
}
catch {
continue
}
if (event.type === 'response.output_text.delta') {
streamedText += event.delta ?? ''
}
else if (event.type === 'response.output_item.done' && event.item) {
streamedItems.push(event.item)
}
else if (event.type === 'response.completed') {
completed = true
output = event.response?.output ?? []
}
else if (event.type === 'response.failed' || event.type === 'response.incomplete') {
failedCode = event.response?.error?.code ?? event.type
}
else if (event.type === 'error') {
failedCode = event.error?.code ?? event.code ?? 'error'
}
}
if (!completed)
return { text: '', calls: [], failedCode: failedCode ?? 'stream_interrupted' }
const items = output.length ? output : streamedItems
const itemText = items.filter(item => item.type === 'message').flatMap(item => item.content ?? []).filter(part => part.type === 'output_text').map(part => part.text ?? '').join('')
const calls = items.filter(item => item.type === 'function_call' && item.call_id && item.name).map(item => ({
type: 'function_call' as const,
call_id: item.call_id!,
name: item.name!,
arguments: item.arguments ?? '{}',
...(item.namespace ? { namespace: item.namespace } : {}),
}))
return { text: itemText || streamedText, calls }
}
/** Map an OpenAI error code to an assistant state the chat knows how to show. */
export function upstreamError(code: string | undefined, status: number): AssistantUpstreamError {
if (code === 'subscription_sharing_usage_limit_exceeded')
return new AssistantUpstreamError('chatgpt_usage_limit')
if (code === 'subscription_sharing_user_not_eligible')
return new AssistantUpstreamError('chatgpt_not_eligible')
if (code === 'subscription_sharing_invalid_user' || status === 401)
return new AssistantUpstreamError('chatgpt_reconnect_required')
return new AssistantUpstreamError('assistant_upstream_error')
}
const modelCache = new Map<string, string>()
/** Pick a model from the signed-in account's catalog (ChatGPT plans differ). */
export async function resolveModel(accessToken: string): Promise<string> {
const cached = modelCache.get(accessToken)
if (cached)
return cached
let slug = PREFERRED_MODEL
try {
const response = await assistantOpenAIRequest('models', accessToken, { method: 'GET', timeoutMs: 10_000 })
if (response.status === 200) {
const body = JSON.parse(response.text) as { models?: Array<{ slug?: string, visibility?: string }> }
const listed = (body.models ?? []).filter(model => model.slug && model.visibility === 'list').map(model => model.slug!)
slug = listed.includes(PREFERRED_MODEL) ? PREFERRED_MODEL : (listed[0] ?? PREFERRED_MODEL)
modelCache.set(accessToken, slug)
}
}
catch {}
return slug
}
async function callModel(accessToken: string, prompt: string, input: InputItem[], tools: unknown[], allowTools: boolean): Promise<ModelTurn> {
const model = await resolveModel(accessToken)
let response
try {
response = await assistantOpenAIRequest('responses', accessToken, {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'text/event-stream' },
// ChatGPT plan usage requires store: false and stream: true. Tools stay declared on the last
// turn (history references them); tool_choice none forces a plain answer.
body: JSON.stringify({ model, instructions: prompt, input, tools, store: false, stream: true, ...(allowTools ? {} : { tool_choice: 'none' }) }),
timeoutMs: MODEL_TIMEOUT_MS,
})
}
catch (error) {
if (error instanceof Error && error.message === 'assistant_session_required')
throw new AssistantUpstreamError('assistant_session_required')
throw new AssistantUpstreamError('assistant_upstream_error')
}
if (response.status < 200 || response.status >= 300) {
let code: string | undefined
try {
code = (JSON.parse(response.text) as { error?: { code?: string } }).error?.code
}
catch {}
throw upstreamError(code, response.status)
}
const turn = parseResponseStream(response.text)
if (turn.failedCode)
throw upstreamError(turn.failedCode, 200)
return turn
}
// ---------------------------------------------------------------------------
// Agent loop
// ---------------------------------------------------------------------------
export interface RunAssistantOptions {
accessToken: string
apikey: string
messages: AssistantTurn[]
context: AssistantContext
confirm?: { tool: string, arguments: Record<string, unknown> }
}
export async function runAssistant(options: RunAssistantOptions): Promise<AssistantResponse> {
const cards: AssistantCard[] = []
const actions: AssistantAction[] = []
const mcpTools = await listMcpTools(options.apikey)
const toolsByName = new Map(mcpTools.map(tool => [tool.name, tool]))
const tools = responsesTools(mcpTools)
const prompt = instructions(options.context)
const input: InputItem[] = options.messages.filter(turn => turn.content.trim()).slice(-40).map(turn => ({ role: turn.role, content: turn.content }))
if (options.confirm) {
const tool = toolsByName.get(options.confirm.tool)
if (!tool || !needsConfirmation(tool))
throw new Error('invalid_confirm')
const result = await callMcpTool(options.apikey, tool.name, options.confirm.arguments)
actions.push({ tool: tool.name, title: tool.title ?? tool.annotations?.title ?? tool.name, ok: result.ok })
input.push({
role: 'user',
content: `I confirmed the action ${tool.name} with ${JSON.stringify(options.confirm.arguments)}. Result (${result.ok ? 'success' : 'error'}):\n${clip(result.text)}\nSummarize the outcome and link the affected page.`,
})
}
// A confirmed action already ran: if the summary call fails, still report it instead of an error.
let confirmedActionNeedsFallback = !!options.confirm && actions.at(-1)?.ok === true
for (let round = 0; round < MAX_ROUNDS; round++) {
let turn: ModelTurn
try {
turn = await callModel(options.accessToken, prompt, input, tools, true)
}
catch (error) {
if (confirmedActionNeedsFallback)
return { reply: 'Done. The action completed, but I could not write a summary.', cards, actions }
throw error
}
confirmedActionNeedsFallback = false
if (!turn.calls.length)
return { reply: turn.text, cards, actions }
// store: false, so the whole history (including our function calls) is resent each round.
if (turn.text)
input.push({ role: 'assistant', content: turn.text })
let waitForUser = false
for (const call of turn.calls) {
input.push(call)
const args = parseArguments(call.arguments)
let output: string
if (call.name === 'show_link') {
const path = sanitizeConsolePath(args.path)
if (path) {
cards.push({ type: 'link', title: String(args.title ?? path).slice(0, 120), description: typeof args.description === 'string' ? args.description.slice(0, 300) : undefined, path })
output = 'Card shown to the user.'
}
else {
output = 'Invalid path: use a console path starting with a single /.'
}
}
else if (call.name === 'ask_user') {
const options = Array.isArray(args.options) ? args.options.filter((option): option is string => typeof option === 'string' && option.trim() !== '').slice(0, 8).map(option => option.slice(0, 120)) : []
cards.push({ type: 'question', question: String(args.question ?? '').slice(0, 500), options, allowFreeText: args.allow_free_text === true })
waitForUser = true
output = 'Question shown. Stop here and wait for the user to answer.'
}
else {
const tool = toolsByName.get(call.name)
if (!tool) {
output = `Unknown tool: ${call.name}`
}
else if (needsConfirmation(tool)) {
cards.push({ type: 'confirm', title: tool.title ?? tool.annotations?.title ?? tool.name, description: tool.description.split('. ')[0], tool: tool.name, arguments: args })
waitForUser = true
output = 'Not run yet: the user must click the confirm card. Tell them briefly what will happen.'
}
else {
const result = await callMcpTool(options.apikey, tool.name, args)
actions.push({ tool: tool.name, title: tool.title ?? tool.annotations?.title ?? tool.name, ok: result.ok })
output = clip(result.text)
}
}
input.push({ type: 'function_call_output', call_id: call.call_id, output })
}
if (waitForUser) {
// Let the model add one short sentence, without more tool calls, then hand back to the user.
input.push({ role: 'developer', content: 'Write one short sentence for the user now. Do not call tools.' })
const final = await callModel(options.accessToken, prompt, input, tools, false).catch(() => ({ text: '' }))
return { reply: final.text, cards, actions }
}
}
return { reply: 'I stopped after too many steps. Try a narrower question.', cards, actions }
}