diff --git a/README.md b/README.md index 0bab22f..d4d15ee 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Quant Agent Tools Action -A GitHub Action for registering and updating custom tools on the Quant platform for use by AI agents. Reads tool definitions from a directory structure and creates or updates tools via the API. +A GitHub Action for registering and updating custom tools on the Quant platform for use by AI agents. Reads tool definitions from a directory structure and registers each tool via the API — the platform deploys the tool's edge function itself and registers the tool against it. ## Features - **Tool Registration**: Creates new tools or updates existing ones based on `tool.json` -- **Name-Based Matching**: Detects existing tools by name to avoid duplicates -- **Full Schema Support**: Supports input/output schemas, auth config, execution modes, and more +- **Edge Function Deployment**: Sends each tool's `fn.js` source to the platform, which deploys it and computes the function URL — no separate edge-function deploy step needed +- **Upsert Semantics**: The API upserts by tool name — existing tools are updated in place and their edge function code redeployed under the same uuid +- **Full Schema Support**: Supports input/output schemas, response modes, async execution, and timeouts ## Inputs @@ -16,6 +17,8 @@ A GitHub Action for registering and updating custom tools on the Quant platform | `quant_organization` | Yes | Quant organisation name | | `tools_dir` | Yes | Directory containing tool definitions (default: `tools/`) | | `base_url` | No | Quant API base URL | +| `preview_domain` | No | **Deprecated** — ignored. The platform computes edge function URLs itself | +| `quant_project` | No | **Deprecated** — ignored. The platform computes edge function URLs itself | ## Outputs @@ -25,22 +28,26 @@ A GitHub Action for registering and updating custom tools on the Quant platform ## Directory Structure +Each tool lives in its own subdirectory containing both its definition and its edge function source: + ``` tools/ my-tool/ tool.json # Required — tool definition + fn.js # Required — edge function source, deployed by the platform ``` +The action fails with a clear error if `fn.js` is missing. The platform deploys `fn.js` as the tool's edge function and registers the tool against the deployed function's URL — you do not (and cannot) supply the URL yourself. + ## `tool.json` -Full tool definition matching the Quant AI API: +Tool definition matching the Quant AI API: ```json { "toolName": "quantassure_compliance_assessment", "description": "Submit a structured compliance assessment", "category": "assessment", - "executionMode": "client", "responseMode": "direct", "timeout": 30, "inputSchema": { @@ -71,15 +78,14 @@ Full tool definition matching the Quant AI API: | `description` | Yes | Description of what the tool does | | `inputSchema` | Yes | JSON Schema defining the tool's input parameters | | `category` | No | Tool category for organisation | -| `executionMode` | No | `edge_function` or `client` | -| `edgeFunctionUrl` | No | URL for edge function execution | -| `isAsync` | No | Whether the tool runs asynchronously | | `responseMode` | No | `direct` or `llm` | -| `timeout` | No | Timeout in seconds | +| `isAsync` | No | Whether the tool runs asynchronously | +| `timeout` | No | Timeout in **seconds** (5–300). Values above 300 are assumed to be milliseconds from older configs and converted with a warning | | `outputSchema` | No | JSON Schema for tool output | | `outputSchemaDescription` | No | Description of the output schema | -| `authConfig` | No | Auth configuration (`type`: `bearer`, `api-key`, or `none`) | -| `version` | No | Tool version string | +| `uuid` | No | **Deprecated** — informational only. The platform manages edge function uuids (see migration notes) | +| `edgeFunctionUrl` | No | **Deprecated** — ignored. The platform computes the URL from the deployed function | +| `executionMode` | No | **Deprecated** — ignored. All tools are deployed as edge functions | ## Usage @@ -124,13 +130,24 @@ jobs: agents_dir: agents/ ``` +## Migrating from Earlier Versions + +Earlier versions of this action built an edge function URL from a `uuid` in `tool.json` plus the `preview_domain`/`quant_project` inputs, and assumed the edge function was deployed by a separate workflow step. The platform API now owns both concerns: + +- **Add `fn.js`** to each tool directory — the edge function source the platform should deploy. +- **Remove separate edge-function deploy steps** for these tools from your workflow — they are redundant; the registration call deploys the code. +- **`preview_domain` and `quant_project` inputs** can be removed from your workflow — they are accepted but ignored. +- **`uuid` in `tool.json`** is now informational only, and the action warns when it is present. The platform reuses the existing tool's uuid on update, so a uuid aligned with your `functions.json` stays stable **only if** the tool is already registered with that uuid in its edge function URL. If the tool has never been registered (or registration previously failed), the platform assigns a fresh uuid on first registration — update any external references accordingly. + ## Error Handling The action will fail if: - The API key or organization is invalid - The tools directory does not exist - A `tool.json` is missing required fields (`toolName`, `description`, `inputSchema`) -- The API returns an error during tool registration +- A tool directory is missing `fn.js` +- A `timeout` does not resolve to 5–300 seconds +- The API returns an error during tool registration (the failure message includes the HTTP status and full JSON response body, including Laravel validation errors) ## Development @@ -141,11 +158,7 @@ npm install npm run build ``` -### Testing - -```bash -npm test -``` +The action ships its compiled `dist/` — commit the build output alongside source changes. ## License diff --git a/action.yml b/action.yml index a30bebd..fe233dc 100644 --- a/action.yml +++ b/action.yml @@ -18,10 +18,10 @@ inputs: description: 'Quant API base URL' required: false preview_domain: - description: 'Preview domain for edge function URLs (e.g. abc123.my-org.quantcdn.io)' + description: 'DEPRECATED — no longer used. The platform deploys edge functions and computes their URLs itself.' required: false quant_project: - description: 'Quant project machine name (used in edge function URL path)' + description: 'DEPRECATED — no longer used. The platform deploys edge functions and computes their URLs itself.' required: false outputs: deployed_tools: diff --git a/dist/index.js b/dist/index.js index fc4e333..0a3eeec 100644 --- a/dist/index.js +++ b/dist/index.js @@ -48221,14 +48221,62 @@ const fs = __importStar(__nccwpck_require__(9896)); const path = __importStar(__nccwpck_require__(6928)); const quant_client_1 = __nccwpck_require__(6491); const DEFAULT_BASE_URL = 'https://dashboard.quantcdn.io'; +// The v3 endpoint validates timeoutSeconds within this range. +const TIMEOUT_MIN_SECONDS = 5; +const TIMEOUT_MAX_SECONDS = 300; +/** + * Extract HTTP status and the full JSON response body from an axios-style + * error. Laravel validation failures return `{ errors: { field: [...] } }` — + * reading only `.error` (as this action previously did) loses everything. + */ +function formatApiError(err) { + if (typeof err === 'object' && err !== null && 'response' in err) { + const response = err.response; + if (response) { + const status = response.status !== undefined ? `HTTP ${response.status}` : 'HTTP error'; + const body = response.data !== undefined ? JSON.stringify(response.data) : '(no response body)'; + return `${status}: ${body}`; + } + } + if (err instanceof Error) { + return err.message; + } + return String(err); +} +/** + * Normalize tool.json `timeout` to seconds for the API's `timeoutSeconds` + * field. The unit is seconds; values above the API maximum (300) are assumed + * to be milliseconds from older configs and converted with a warning. + * Returns undefined (and fails the action) on out-of-range values. + */ +function resolveTimeoutSeconds(toolName, timeout) { + let seconds = timeout; + if (seconds > TIMEOUT_MAX_SECONDS) { + seconds = Math.round(seconds / 1000); + core.warning(`Tool ${toolName}: timeout ${timeout} exceeds the ${TIMEOUT_MAX_SECONDS}s maximum — ` + + `assuming milliseconds and converting to ${seconds}s. Specify timeout in seconds.`); + } + if (seconds < TIMEOUT_MIN_SECONDS || seconds > TIMEOUT_MAX_SECONDS) { + core.setFailed(`Tool ${toolName}: timeout must resolve to ${TIMEOUT_MIN_SECONDS}-${TIMEOUT_MAX_SECONDS} seconds (got ${seconds})`); + return undefined; + } + return seconds; +} async function run() { try { const apiKey = core.getInput('quant_api_key', { required: true }); const organization = core.getInput('quant_organization', { required: true }); const toolsDir = core.getInput('tools_dir', { required: true }); const baseUrl = core.getInput('base_url') || DEFAULT_BASE_URL; - const previewDomain = core.getInput('preview_domain'); - const project = core.getInput('quant_project'); + // Deprecated inputs — the platform now deploys edge functions and computes + // their URLs itself, so these are no longer used. Accepted (not removed + // from action.yml) so existing workflows don't break. + if (core.getInput('preview_domain')) { + core.warning('Input preview_domain is deprecated and ignored — the platform computes edge function URLs.'); + } + if (core.getInput('quant_project')) { + core.warning('Input quant_project is deprecated and ignored — the platform computes edge function URLs.'); + } // The SDK appends /api/v3/... paths internally, so strip it if provided. const basePath = baseUrl.replace(/\/api\/v3\/?$/, ''); const config = new quant_client_1.Configuration({ @@ -48241,21 +48289,25 @@ async function run() { core.setFailed(`Tools directory not found: ${resolvedDir}`); return; } - // Fetch existing tools once for name-based matching. + // Fetch existing tools once for accurate created/updated reporting and to + // fail fast on auth problems. Note the create endpoint upserts by name + // regardless — this list is not required for correctness. core.info('Fetching existing tools...'); const existingTools = new Set(); try { const listResponse = await toolsApi.listCustomTools(organization); for (const tool of listResponse.data.tools || []) { - if (tool.name) { - existingTools.add(tool.name); + // The API returns `toolName`; the generated SDK type says `name`. + // Read both to be safe against either shape. + const name = tool.toolName ?? tool.name; + if (typeof name === 'string' && name) { + existingTools.add(name); } } core.info(` Found ${existingTools.size} existing tool(s)`); } catch (err) { - const message = err.response?.data?.error || err.message || String(err); - core.setFailed(`Failed to list existing tools: ${message}`); + core.setFailed(`Failed to list existing tools: ${formatApiError(err)}`); return; } const deployedTools = []; @@ -48274,45 +48326,52 @@ async function run() { core.setFailed(`Tool in ${entry.name}: missing required fields (toolName, description, inputSchema)`); return; } - const isUpdate = existingTools.has(toolConfig.toolName); - core.info(`${isUpdate ? 'Updating' : 'Creating'} tool: ${toolConfig.toolName}`); - // Map tool.json to SDK request. The SDK interface is a subset of what the - // API accepts — spread the full config so extra fields (category, - // executionMode, responseMode, etc.) are sent through to the API. - // Build edgeFunctionUrl from UUID if preview_domain is configured - let edgeFunctionUrl = toolConfig.edgeFunctionUrl || ''; - if (toolConfig.uuid && previewDomain && project) { - edgeFunctionUrl = `https://${previewDomain}/_quant/ai-exec/${organization}/${project}/${toolConfig.uuid}`; - core.info(` Edge function URL: ${edgeFunctionUrl}`); - } - else if (toolConfig.uuid && (!previewDomain || !project)) { - core.setFailed(`Tool ${toolConfig.toolName} has uuid but missing preview_domain or quant_project inputs`); + // Convention: each tool directory contains fn.js — the edge function + // source deployed by the platform when the tool is registered. + const fnPath = path.join(toolDir, 'fn.js'); + if (!fs.existsSync(fnPath)) { + core.setFailed(`Tool ${toolConfig.toolName}: missing fn.js in ${toolDir}. ` + + `Each tool directory must contain fn.js (the edge function source) alongside tool.json — ` + + `the platform deploys it and registers the tool against the deployed function.`); return; } + const edgeFunctionCode = fs.readFileSync(fnPath, 'utf-8'); + if (toolConfig.uuid) { + core.warning(`Tool ${toolConfig.toolName}: tool.json "uuid" is informational only — the platform manages ` + + `edge function uuids, reusing the existing tool's uuid on update.`); + } + const isUpdate = existingTools.has(toolConfig.toolName); + core.info(`${isUpdate ? 'Updating' : 'Creating'} tool: ${toolConfig.toolName}`); const request = { - name: toolConfig.toolName, + toolName: toolConfig.toolName, description: toolConfig.description, - edgeFunctionUrl: edgeFunctionUrl, + edgeFunctionCode, inputSchema: toolConfig.inputSchema, - isAsync: toolConfig.isAsync, - timeoutSeconds: toolConfig.timeout, - // Pass through fields the SDK doesn't type but the API accepts. - ...(toolConfig.category && { category: toolConfig.category }), - ...(toolConfig.executionMode && { executionMode: toolConfig.executionMode }), - ...(toolConfig.responseMode && { responseMode: toolConfig.responseMode }), - ...(toolConfig.outputSchema && { outputSchema: toolConfig.outputSchema }), - ...(toolConfig.outputSchemaDescription && { outputSchemaDescription: toolConfig.outputSchemaDescription }), - ...(toolConfig.authConfig && { authConfig: toolConfig.authConfig }), - ...(toolConfig.version && { version: toolConfig.version }), + ...(toolConfig.category !== undefined && { category: toolConfig.category }), + ...(toolConfig.responseMode !== undefined && { responseMode: toolConfig.responseMode }), + ...(toolConfig.outputSchema !== undefined && { outputSchema: toolConfig.outputSchema }), + ...(toolConfig.outputSchemaDescription !== undefined && { + outputSchemaDescription: toolConfig.outputSchemaDescription, + }), + ...(toolConfig.isAsync !== undefined && { isAsync: toolConfig.isAsync }), }; + if (toolConfig.timeout !== undefined) { + const timeoutSeconds = resolveTimeoutSeconds(toolConfig.toolName, toolConfig.timeout); + if (timeoutSeconds === undefined) + return; + request.timeoutSeconds = timeoutSeconds; + } try { + // Cast: the generated SDK request type is stale (requires + // edgeFunctionUrl, missing edgeFunctionCode) — see CustomToolRequest. + // The endpoint upserts by name: existing tools are updated in place + // and their edge function code redeployed under the same uuid. await toolsApi.createCustomTool(organization, request); core.info(` Tool ${isUpdate ? 'updated' : 'created'} successfully`); deployedTools.push({ toolName: toolConfig.toolName, created: !isUpdate }); } catch (err) { - const message = err.response?.data?.error || err.message || String(err); - core.setFailed(`Failed to register tool ${toolConfig.toolName}: ${message}`); + core.setFailed(`Failed to register tool ${toolConfig.toolName}: ${formatApiError(err)}`); return; } } diff --git a/src/index.ts b/src/index.ts index 055d5cc..b89ecdb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,15 +5,23 @@ import { Configuration, AICustomToolsApi, CreateCustomToolRequest } from '@quant const DEFAULT_BASE_URL = 'https://dashboard.quantcdn.io'; +// The v3 endpoint validates timeoutSeconds within this range. +const TIMEOUT_MIN_SECONDS = 5; +const TIMEOUT_MAX_SECONDS = 300; + interface ToolConfig { toolName: string; description: string; category?: string; + /** Deprecated — the platform deploys the edge function itself. Ignored. */ executionMode?: 'edge_function' | 'client'; + /** Deprecated — the platform computes the edge function URL itself. Ignored. */ edgeFunctionUrl?: string; + /** Deprecated — the platform manages tool uuids. Informational only. */ uuid?: string; isAsync?: boolean; responseMode?: 'direct' | 'llm'; + /** Timeout in seconds (5-300). Values > 300 are assumed to be milliseconds and converted. */ timeout?: number; inputSchema: Record; outputSchema?: Record; @@ -30,14 +38,87 @@ interface DeployedTool { created: boolean; } +/** + * The request shape the v3 create-custom-tool endpoint actually validates. + * + * The generated `CreateCustomToolRequest` type in @quantcdn/quant-client is out + * of date: it requires `edgeFunctionUrl`, but the endpoint requires + * `edgeFunctionCode` instead — the platform deploys the edge function itself, + * computes the URL, and registers the tool under it. + */ +interface CustomToolRequest { + toolName: string; + description: string; + edgeFunctionCode: string; + inputSchema: Record; + category?: string; + responseMode?: 'direct' | 'llm'; + outputSchema?: Record; + outputSchemaDescription?: string; + isAsync?: boolean; + timeoutSeconds?: number; +} + +/** + * Extract HTTP status and the full JSON response body from an axios-style + * error. Laravel validation failures return `{ errors: { field: [...] } }` — + * reading only `.error` (as this action previously did) loses everything. + */ +function formatApiError(err: unknown): string { + if (typeof err === 'object' && err !== null && 'response' in err) { + const response = (err as { response?: { status?: number; data?: unknown } }).response; + if (response) { + const status = response.status !== undefined ? `HTTP ${response.status}` : 'HTTP error'; + const body = response.data !== undefined ? JSON.stringify(response.data) : '(no response body)'; + return `${status}: ${body}`; + } + } + if (err instanceof Error) { + return err.message; + } + return String(err); +} + +/** + * Normalize tool.json `timeout` to seconds for the API's `timeoutSeconds` + * field. The unit is seconds; values above the API maximum (300) are assumed + * to be milliseconds from older configs and converted with a warning. + * Returns undefined (and fails the action) on out-of-range values. + */ +function resolveTimeoutSeconds(toolName: string, timeout: number): number | undefined { + let seconds = timeout; + if (seconds > TIMEOUT_MAX_SECONDS) { + seconds = Math.round(seconds / 1000); + core.warning( + `Tool ${toolName}: timeout ${timeout} exceeds the ${TIMEOUT_MAX_SECONDS}s maximum — ` + + `assuming milliseconds and converting to ${seconds}s. Specify timeout in seconds.` + ); + } + if (seconds < TIMEOUT_MIN_SECONDS || seconds > TIMEOUT_MAX_SECONDS) { + core.setFailed( + `Tool ${toolName}: timeout must resolve to ${TIMEOUT_MIN_SECONDS}-${TIMEOUT_MAX_SECONDS} seconds (got ${seconds})` + ); + return undefined; + } + return seconds; +} + async function run(): Promise { try { const apiKey = core.getInput('quant_api_key', { required: true }); const organization = core.getInput('quant_organization', { required: true }); const toolsDir = core.getInput('tools_dir', { required: true }); const baseUrl = core.getInput('base_url') || DEFAULT_BASE_URL; - const previewDomain = core.getInput('preview_domain'); - const project = core.getInput('quant_project'); + + // Deprecated inputs — the platform now deploys edge functions and computes + // their URLs itself, so these are no longer used. Accepted (not removed + // from action.yml) so existing workflows don't break. + if (core.getInput('preview_domain')) { + core.warning('Input preview_domain is deprecated and ignored — the platform computes edge function URLs.'); + } + if (core.getInput('quant_project')) { + core.warning('Input quant_project is deprecated and ignored — the platform computes edge function URLs.'); + } // The SDK appends /api/v3/... paths internally, so strip it if provided. const basePath = baseUrl.replace(/\/api\/v3\/?$/, ''); @@ -54,20 +135,24 @@ async function run(): Promise { return; } - // Fetch existing tools once for name-based matching. + // Fetch existing tools once for accurate created/updated reporting and to + // fail fast on auth problems. Note the create endpoint upserts by name + // regardless — this list is not required for correctness. core.info('Fetching existing tools...'); const existingTools = new Set(); try { const listResponse = await toolsApi.listCustomTools(organization); for (const tool of listResponse.data.tools || []) { - if (tool.name) { - existingTools.add(tool.name); + // The API returns `toolName`; the generated SDK type says `name`. + // Read both to be safe against either shape. + const name = (tool as Record).toolName ?? tool.name; + if (typeof name === 'string' && name) { + existingTools.add(name); } } core.info(` Found ${existingTools.size} existing tool(s)`); - } catch (err: any) { - const message = err.response?.data?.error || err.message || String(err); - core.setFailed(`Failed to list existing tools: ${message}`); + } catch (err: unknown) { + core.setFailed(`Failed to list existing tools: ${formatApiError(err)}`); return; } @@ -92,46 +177,59 @@ async function run(): Promise { return; } - const isUpdate = existingTools.has(toolConfig.toolName); - core.info(`${isUpdate ? 'Updating' : 'Creating'} tool: ${toolConfig.toolName}`); - - // Map tool.json to SDK request. The SDK interface is a subset of what the - // API accepts — spread the full config so extra fields (category, - // executionMode, responseMode, etc.) are sent through to the API. - // Build edgeFunctionUrl from UUID if preview_domain is configured - let edgeFunctionUrl = toolConfig.edgeFunctionUrl || ''; - if (toolConfig.uuid && previewDomain && project) { - edgeFunctionUrl = `https://${previewDomain}/_quant/ai-exec/${organization}/${project}/${toolConfig.uuid}`; - core.info(` Edge function URL: ${edgeFunctionUrl}`); - } else if (toolConfig.uuid && (!previewDomain || !project)) { - core.setFailed(`Tool ${toolConfig.toolName} has uuid but missing preview_domain or quant_project inputs`); + // Convention: each tool directory contains fn.js — the edge function + // source deployed by the platform when the tool is registered. + const fnPath = path.join(toolDir, 'fn.js'); + if (!fs.existsSync(fnPath)) { + core.setFailed( + `Tool ${toolConfig.toolName}: missing fn.js in ${toolDir}. ` + + `Each tool directory must contain fn.js (the edge function source) alongside tool.json — ` + + `the platform deploys it and registers the tool against the deployed function.` + ); return; } + const edgeFunctionCode = fs.readFileSync(fnPath, 'utf-8'); + + if (toolConfig.uuid) { + core.warning( + `Tool ${toolConfig.toolName}: tool.json "uuid" is informational only — the platform manages ` + + `edge function uuids, reusing the existing tool's uuid on update.` + ); + } + + const isUpdate = existingTools.has(toolConfig.toolName); + core.info(`${isUpdate ? 'Updating' : 'Creating'} tool: ${toolConfig.toolName}`); - const request: CreateCustomToolRequest & Record = { - name: toolConfig.toolName, + const request: CustomToolRequest = { + toolName: toolConfig.toolName, description: toolConfig.description, - edgeFunctionUrl: edgeFunctionUrl, + edgeFunctionCode, inputSchema: toolConfig.inputSchema, - isAsync: toolConfig.isAsync, - timeoutSeconds: toolConfig.timeout, - // Pass through fields the SDK doesn't type but the API accepts. - ...(toolConfig.category && { category: toolConfig.category }), - ...(toolConfig.executionMode && { executionMode: toolConfig.executionMode }), - ...(toolConfig.responseMode && { responseMode: toolConfig.responseMode }), - ...(toolConfig.outputSchema && { outputSchema: toolConfig.outputSchema }), - ...(toolConfig.outputSchemaDescription && { outputSchemaDescription: toolConfig.outputSchemaDescription }), - ...(toolConfig.authConfig && { authConfig: toolConfig.authConfig }), - ...(toolConfig.version && { version: toolConfig.version }), + ...(toolConfig.category !== undefined && { category: toolConfig.category }), + ...(toolConfig.responseMode !== undefined && { responseMode: toolConfig.responseMode }), + ...(toolConfig.outputSchema !== undefined && { outputSchema: toolConfig.outputSchema }), + ...(toolConfig.outputSchemaDescription !== undefined && { + outputSchemaDescription: toolConfig.outputSchemaDescription, + }), + ...(toolConfig.isAsync !== undefined && { isAsync: toolConfig.isAsync }), }; + if (toolConfig.timeout !== undefined) { + const timeoutSeconds = resolveTimeoutSeconds(toolConfig.toolName, toolConfig.timeout); + if (timeoutSeconds === undefined) return; + request.timeoutSeconds = timeoutSeconds; + } + try { - await toolsApi.createCustomTool(organization, request); + // Cast: the generated SDK request type is stale (requires + // edgeFunctionUrl, missing edgeFunctionCode) — see CustomToolRequest. + // The endpoint upserts by name: existing tools are updated in place + // and their edge function code redeployed under the same uuid. + await toolsApi.createCustomTool(organization, request as unknown as CreateCustomToolRequest); core.info(` Tool ${isUpdate ? 'updated' : 'created'} successfully`); deployedTools.push({ toolName: toolConfig.toolName, created: !isUpdate }); - } catch (err: any) { - const message = err.response?.data?.error || err.message || String(err); - core.setFailed(`Failed to register tool ${toolConfig.toolName}: ${message}`); + } catch (err: unknown) { + core.setFailed(`Failed to register tool ${toolConfig.toolName}: ${formatApiError(err)}`); return; } }