Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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": {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
127 changes: 93 additions & 34 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 = [];
Expand All @@ -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;
}
}
Expand Down
Loading
Loading