fix(agiloft): align the integration with the documented ewws REST interface (#6556)

* fix(agiloft): align the integration with the documented ewws REST interface

The CRUD tools targeted /ewws/REST/{kb}/{table}/{id} with JSON bodies and
guessed at the response by probing `data.result ?? data` and `id ?? ID`.
Agiloft documents that path as a URL convention only -- no method table, no
example call, and no response shape -- and no known client uses it. The EW*
operation family is specified end to end, including exact response bodies, so
every operation now goes through it and parses the documented
`EWREST_key='value';` assignment format.

- EWCreate/EWRead/EWUpdate/EWDelete/EWSearch/EWSelect/EWGetChoiceLineId are
  form-encoded and parsed via a shared EWREST parser; the /.json suffix is kept
  only on EWAttachInfo, the one operation with a published JSON sample
- EWDelete now sends the deleteRule the docs require, defaulting to
  ERROR_IF_DEPENDANTS so a delete fails rather than cascading
- EWRemoveAttachment uses GET; it does not accept DELETE
- EWSearch accepts the documented `search` saved-search label, so saved
  searches are reachable for the first time
- Search query help taught AND/OR; Agiloft uses && and ||
- Add run_action_button (POST /ewws/async/EWActionButton) for approvals and
  send-for-signature steps
- Drop saved_search: EWSavedSearch has no doc page, so neither its URL nor its
  response could be verified and it could only ever return an empty list
- Add force on unlock, filter read fields locally since $fields is
  undocumented, correct lock status to LOCKED/NO_LOCK, and stop reporting a
  fabricated page size of 25

* fix(agiloft): fail loudly on non-EWREST bodies and keep the retired tool resolvable

- EWSearch and EWSelect report an empty result set as `EWREST_id_length = '0';`,
  so a body with no assignments at all is a refusal Agiloft returned with HTTP
  200, not an empty result. Both routes now surface it as an error instead of a
  successful empty list.
- Re-register agiloft_saved_search as a retired tool. Removing it outright left
  workflows saved with operation='saved_search' deriving a tool id the registry
  no longer provided, which throws "Tool not found" at execution. It now fails
  through directExecution with a message pointing at the Search Records
  operation's Saved Search field, without issuing an undocumented request. It
  stays out of the operation dropdown so it cannot be chosen for new blocks.
- Guard EWCreate and EWUpdate against oversized record data. Those operations
  carry field values in the query string, so a large payload hits the request
  line limit; the tool now explains that rather than surfacing an opaque 414.
This commit is contained in:
Waleed
2026-08-11 13:27:20 -07:00
committed by GitHub
parent bd91ab73cc
commit 81e04a8e41
32 changed files with 1468 additions and 395 deletions
@@ -34,7 +34,7 @@ In Sim, the Agiloft integration enables your agents to manage contracts and reco
## Usage Instructions
Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.
Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, record locking, and running action buttons across any table in your knowledge base.
@@ -129,6 +129,7 @@ Delete a record from an Agiloft table.
| `password` | string | Yes | Agiloft password |
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
| `recordId` | string | Yes | ID of the record to delete |
| `deleteRule` | string | No | How to treat records that depend on this one: ERROR_IF_DEPENDANTS \(default — fails rather than cascading\), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, or UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE |
#### Output
@@ -174,13 +175,14 @@ Lock, unlock, or check the lock status of an Agiloft record.
| `table` | string | Yes | Table name \(e.g., "contracts"\) |
| `recordId` | string | Yes | ID of the record to lock, unlock, or check |
| `lockAction` | string | Yes | Action to perform: "lock", "unlock", or "check" |
| `force` | boolean | No | Unlock only: release a lock held by another user. Requires membership in the admin group. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Record ID |
| `lockStatus` | string | Lock status \(e.g., "LOCKED", "UNLOCKED"\) |
| `lockStatus` | string | Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free |
| `lockedBy` | string | Username of the user who locked the record |
| `lockExpiresInMinutes` | number | Minutes until the lock expires |
@@ -255,9 +257,9 @@ Download an attached file from an Agiloft record field.
| --------- | ---- | ----------- |
| `file` | file | Downloaded attachment file |
### Agiloft Saved Search
### Agiloft Run Action Button
List saved searches defined for an Agiloft table.
Run an action button on an Agiloft record, such as an approval or send-for-signature step.
#### Input
@@ -267,17 +269,40 @@ List saved searches defined for an Agiloft table.
| `knowledgeBase` | string | Yes | Knowledge base name |
| `login` | string | Yes | Agiloft username |
| `password` | string | Yes | Agiloft password |
| `table` | string | Yes | Table name to list saved searches for \(e.g., "contracts"\) |
| `table` | string | Yes | Table name \(e.g., "contracts", "case"\) |
| `recordId` | string | Yes | ID of the record to run the action button on |
| `actionButtonField` | string | Yes | Logical name of the field holding the action button \(e.g., "ab_field"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searches` | array | List of saved searches for the table |
| ↳ `name` | string | Saved search name |
| ↳ `label` | string | Saved search display label |
| ↳ `id` | number | Saved search database identifier |
| ↳ `description` | string | Saved search description |
| `recordId` | string | ID of the record the action button was run on |
| `callbackId` | string | Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID |
### saved_search
### Agiloft Saved Search (retired)
Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `instanceUrl` | string | No | Agiloft instance URL |
| `knowledgeBase` | string | No | Knowledge base name |
| `login` | string | No | Agiloft username |
| `password` | string | No | Agiloft password |
| `table` | string | No | Table name |
| `output` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searches` | array | Always empty; this operation is retired |
### Agiloft Search Records
@@ -292,19 +317,20 @@ Search for records in an Agiloft table using a query.
| `login` | string | Yes | Agiloft username |
| `password` | string | Yes | Agiloft password |
| `table` | string | Yes | Table name to search in \(e.g., "contracts", "contacts.employees"\) |
| `query` | string | Yes | Search query using Agiloft query syntax \(e.g., "status=\'Active\'" or "company_name~=\'Acme\'"\) |
| `query` | string | No | Ad hoc EWSearch query. Combine conditions with && \(and\) or \|\| \(or\) and quote every value — e.g. \"summary~='test'&&priority='High'\". Required unless a saved search is given. |
| `search` | string | No | Label of a saved search defined on the table \(e.g., "C: Status is Closed"\). Can be combined with a query to narrow it further. |
| `fields` | string | No | Comma-separated list of field names to include in the results |
| `page` | string | No | Page number for paginated results \(starting from 0\) |
| `limit` | string | No | Maximum number of records to return per page |
| `limit` | string | No | Maximum number of records to return per page. Agiloft treats 0 as "all records", so leave it unset or use a positive value to keep result sizes bounded. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `records` | json | Array of matching records with their field values |
| `totalCount` | number | Total number of matching records |
| `page` | number | Current page number |
| `limit` | number | Records per page |
| `totalCount` | number | Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set. |
| `page` | number | Page number that was requested \(0-based\) |
| `limit` | number | Page size that was requested; 0 when no limit was sent and Agiloft chose one |
### Agiloft Select Records
@@ -319,7 +345,7 @@ Select record IDs matching a SQL WHERE clause from an Agiloft table.
| `login` | string | Yes | Agiloft username |
| `password` | string | Yes | Agiloft password |
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\) |
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\). EWSelect has no page size and returns every matching ID, so append a database limit such as "limit 0,200" to bound the result. |
#### Output
@@ -0,0 +1,176 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
import { POST } from '@/app/api/tools/agiloft/create_record/route'
import { POST as SEARCH } from '@/app/api/tools/agiloft/search_records/route'
import { POST as SELECT } from '@/app/api/tools/agiloft/select_records/route'
const PINNED_IP = '93.184.216.34'
const baseBody = {
instanceUrl: 'https://example.agiloft.com',
knowledgeBase: 'Demo',
login: 'admin',
password: 'secret',
table: 'contacts.employees',
data: JSON.stringify({ first_name: 'John', last_name: 'Doe' }),
}
function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: string }) {
return {
ok: body.ok ?? true,
status: body.ok === false ? 400 : 200,
statusText: '',
headers: new Headers(),
body: null,
text: async () => body.text ?? '',
json: async () => body.json ?? {},
arrayBuffer: async () => new ArrayBuffer(0),
}
}
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: PINNED_IP,
originalHostname: 'example.agiloft.com',
})
})
describe('POST /api/tools/agiloft/create_record', () => {
it("reads the record ID out of EWCreate's EWREST_id assignment", async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
.mockResolvedValueOnce(mockSecureFetchResponse({ text: "EWREST_id='353';" }))
.mockResolvedValueOnce(mockSecureFetchResponse({}))
const response = await POST(createMockRequest('POST', baseBody))
const data = (await response.json()) as {
success: boolean
output: { id: string | null }
}
expect(data.success).toBe(true)
expect(data.output.id).toBe('353')
const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
expect(operationCall[0]).toContain('/ewws/EWCreate?')
expect(operationCall[0]).toContain('&first_name=John')
expect(operationCall[2]).toMatchObject({ method: 'POST' })
})
it('fails loudly when Agiloft answers 200 with something that is not an EWREST body', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
.mockResolvedValueOnce(
mockSecureFetchResponse({ text: 'Error executing query, please consult logs' })
)
.mockResolvedValueOnce(mockSecureFetchResponse({}))
const response = await POST(createMockRequest('POST', baseBody))
const data = (await response.json()) as { success: boolean; error?: string }
expect(data.success).toBe(false)
expect(data.error).toContain('did not return a record ID')
})
it('rejects a data payload that is not a JSON object', async () => {
const response = await POST(
createMockRequest('POST', { ...baseBody, data: '["not", "an", "object"]' })
)
const data = (await response.json()) as { success: boolean; error?: string }
expect(data.success).toBe(false)
expect(data.error).toContain('must be a JSON object')
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})
})
describe('empty EWREST bodies on search and select', () => {
const listBase = {
instanceUrl: 'https://example.agiloft.com',
knowledgeBase: 'Demo',
login: 'admin',
password: 'secret',
table: 'helpdesk_case',
}
function arrange(text: string) {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok' } }))
.mockResolvedValueOnce(mockSecureFetchResponse({ text }))
.mockResolvedValueOnce(mockSecureFetchResponse({}))
}
it('treats a plain-text refusal from EWSearch as a failure, not an empty result', async () => {
arrange('Error executing query, please consult logs')
const response = await SEARCH(
createMockRequest('POST', { ...listBase, query: "priority='High'" })
)
const data = (await response.json()) as { success: boolean; error?: string }
expect(data.success).toBe(false)
expect(data.error).toContain('did not return search results')
})
it('still reports a genuinely empty EWSearch result as a success', async () => {
arrange("EWREST_id_length = '0';")
const response = await SEARCH(
createMockRequest('POST', { ...listBase, query: "priority='High'" })
)
const data = (await response.json()) as {
success: boolean
output: { records: unknown[]; totalCount: number }
}
expect(data.success).toBe(true)
expect(data.output.records).toEqual([])
expect(data.output.totalCount).toBe(0)
})
it('treats a plain-text refusal from EWSelect as a failure, not an empty result', async () => {
arrange('Error executing query, please consult logs')
const response = await SELECT(
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
)
const data = (await response.json()) as { success: boolean; error?: string }
expect(data.success).toBe(false)
expect(data.error).toContain('did not return a result set')
})
it('still reports a genuinely empty EWSelect result as a success', async () => {
arrange("EWREST_id_length = '0';")
const response = await SELECT(
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
)
const data = (await response.json()) as {
success: boolean
output: { recordIds: string[]; totalCount: number }
}
expect(data.success).toBe(true)
expect(data.output.recordIds).toEqual([])
expect(data.output.totalCount).toBe(0)
})
})
@@ -6,8 +6,9 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
import { buildCreateRecordUrl } from '@/tools/agiloft/utils'
import { buildCreateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
export const dynamic = 'force-dynamic'
@@ -49,46 +50,62 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const params = parsed.data.body
let body: string
let fieldValues: Record<string, unknown>
try {
body = JSON.stringify(JSON.parse(params.data))
const parsedData = JSON.parse(params.data)
if (typeof parsedData !== 'object' || parsedData === null || Array.isArray(parsedData)) {
throw new Error('not an object')
}
fieldValues = parsedData as Record<string, unknown>
} catch {
return NextResponse.json({
success: false,
output: { id: null, fields: {} },
error: 'Invalid JSON in data parameter',
error: 'The data parameter must be a JSON object of field names to values',
})
}
const oversized = recordUrlLengthError(params.instanceUrl, (base) =>
buildCreateRecordUrl(base, params, fieldValues)
)
if (oversized) {
return NextResponse.json({
success: false,
output: { id: null, fields: {} },
error: oversized,
})
}
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
params,
(base) => ({
url: buildCreateRecordUrl(base, params),
url: buildCreateRecordUrl(base, params, fieldValues),
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
async (response) => {
const body = await response.text()
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { id: null, fields: {} },
error: `Agiloft error: ${response.status} - ${errorText}`,
error: `Agiloft error: ${response.status} - ${body}`,
}
}
const data = (await response.json()) as Record<string, unknown>
const result = (data.result ?? data) as Record<string, unknown>
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
/** EWCreate answers with a single assignment: EWREST_id='353'; */
const { id, fields } = toRecord(parseEwRest(body))
return {
success: data.success !== false,
output: {
id: id != null ? String(id) : null,
fields: result ?? {},
},
if (id === null) {
return {
success: false,
output: { id: null, fields },
error: `Agiloft did not return a record ID: ${body.trim() || '(empty response)'}`,
}
}
return { success: true, output: { id, fields } }
}
)
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isEwRestBody } from '@/tools/agiloft/ewrest'
import type { AgiloftDeleteResponse } from '@/tools/agiloft/types'
import { buildDeleteRecordUrl } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
@@ -53,26 +54,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
params,
(base) => ({
url: buildDeleteRecordUrl(base, params),
method: 'DELETE',
headers: { Accept: 'application/json' },
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
async (response) => {
const body = (await response.text()).trim()
const recordId = params.recordId.trim()
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { id: params.recordId?.trim() ?? '', deleted: false },
error: `Agiloft error: ${response.status} - ${errorText}`,
output: { id: recordId, deleted: false },
error: `Agiloft error: ${response.status} - ${body}`,
}
}
return {
success: true,
output: {
id: params.recordId?.trim() ?? '',
deleted: true,
},
/**
* EWDelete returns nothing on success and an error message on failure,
* so a non-empty body that is not an EWREST assignment is a refusal the
* HTTP status did not surface — most often the delete rule rejecting
* dependent records.
*/
if (body && !isEwRestBody(body)) {
return {
success: false,
output: { id: recordId, deleted: false },
error: `Agiloft refused the delete: ${body}`,
}
}
return { success: true, output: { id: recordId, deleted: true } }
}
)
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseEwRest } from '@/tools/agiloft/ewrest'
import type { AgiloftGetChoiceLineIdResponse } from '@/tools/agiloft/types'
import { buildGetChoiceLineIdUrl } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
@@ -56,35 +57,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
(base) => ({
url: buildGetChoiceLineIdUrl(base, params),
method: 'GET',
headers: { Accept: 'application/json' },
}),
async (response) => {
const body = await response.text()
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { choiceLineId: null },
error: `Agiloft error: ${response.status} - ${errorText}`,
error: `Agiloft error: ${response.status} - ${body}`,
}
}
const data = (await response.json()) as Record<string, unknown>
const result = data.result ?? data
/**
* The docs state only that EWGetChoiceLineId "returns the ID of the
* choice list element" without naming the assignment key, so the first
* numeric EWREST_ value is taken rather than guessing a key name.
*/
let choiceLineId: number | null = null
if (typeof result === 'number') {
choiceLineId = result
} else if (typeof result === 'string') {
const parsed = Number(result)
choiceLineId = Number.isFinite(parsed) ? parsed : null
} else if (typeof result === 'object' && result !== null) {
const obj = result as Record<string, unknown>
const idVal = obj.id ?? obj.choiceLineId ?? obj.lineId
if (typeof idVal === 'number') {
choiceLineId = idVal
} else if (typeof idVal === 'string') {
const parsed = Number(idVal)
choiceLineId = Number.isFinite(parsed) ? parsed : null
for (const value of parseEwRest(body).values()) {
const parsedValue = Number(value)
if (value.trim() !== '' && Number.isFinite(parsedValue)) {
choiceLineId = parsedValue
break
}
}
@@ -92,14 +87,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return {
success: false,
output: { choiceLineId: null },
error: `No choice line ID found for value "${params.value}" in field "${params.fieldName}"`,
error: `No choice line ID found for value "${params.value}" in field "${params.fieldName}": ${body.trim() || '(empty response)'}`,
}
}
return {
success: data.success !== false,
output: { choiceLineId },
}
return { success: true, output: { choiceLineId } }
}
)
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
import { buildReadRecordUrl } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
@@ -49,34 +50,53 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const params = parsed.data.body
/**
* EWRead has no documented field-selection parameter — it returns the whole
* record the caller is permitted to see — so the requested subset is
* applied here rather than sent upstream.
*/
const requestedFields = params.fields
?.split(',')
.map((field) => field.trim())
.filter(Boolean)
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
params,
(base) => ({
url: buildReadRecordUrl(base, params),
method: 'GET',
headers: { Accept: 'application/json' },
}),
async (response) => {
const body = await response.text()
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { id: null, fields: {} },
error: `Agiloft error: ${response.status} - ${errorText}`,
error: `Agiloft error: ${response.status} - ${body}`,
}
}
const data = (await response.json()) as Record<string, unknown>
const result = (data.result ?? data) as Record<string, unknown>
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
return {
success: data.success !== false,
output: {
id: id != null ? String(id) : null,
fields: result ?? {},
},
const values = parseEwRest(body)
if (values.size === 0) {
return {
success: false,
output: { id: null, fields: {} },
error: `Agiloft returned no record data: ${body.trim() || '(empty response)'}`,
}
}
const { id, fields } = toRecord(values)
if (!requestedFields?.length) {
return { success: true, output: { id, fields } }
}
const selected: Record<string, string> = {}
for (const field of requestedFields) {
if (field in fields) selected[field] = fields[field]
}
return { success: true, output: { id, fields: selected } }
}
)
@@ -0,0 +1,76 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
import { POST } from '@/app/api/tools/agiloft/remove_attachment/route'
const PINNED_IP = '93.184.216.34'
const baseBody = {
instanceUrl: 'https://example.agiloft.com',
knowledgeBase: 'demo',
login: 'admin',
password: 'secret',
table: 'contracts',
recordId: '42',
fieldName: 'attachments',
position: '0',
}
function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: string }) {
return {
ok: body.ok ?? true,
status: 200,
statusText: '',
headers: new Headers(),
body: null,
text: async () => body.text ?? '',
json: async () => body.json ?? {},
arrayBuffer: async () => new ArrayBuffer(0),
}
}
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: PINNED_IP,
originalHostname: 'example.agiloft.com',
})
})
describe('POST /api/tools/agiloft/remove_attachment', () => {
it('calls EWRemoveAttachment with GET, the only verb it accepts besides POST', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-rm' } }))
.mockResolvedValueOnce(mockSecureFetchResponse({ text: '2' }))
.mockResolvedValueOnce(mockSecureFetchResponse({}))
const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(200)
const data = (await response.json()) as {
success: boolean
output: { remainingAttachments: number }
}
expect(data.output.remainingAttachments).toBe(2)
const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
expect(operationCall[0]).toContain('/ewws/EWRemoveAttachment')
expect(operationCall[2]).toMatchObject({ method: 'GET' })
})
})
@@ -55,7 +55,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
params,
(base) => ({
url: buildRemoveAttachmentUrl(base, params),
method: 'DELETE',
/** EWRemoveAttachment is a GET/POST operation; it does not accept DELETE. */
method: 'GET',
}),
async (response) => {
const text = await response.text()
@@ -1,18 +1,19 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { agiloftSavedSearchContract } from '@/lib/api/contracts/tools/agiloft'
import { agiloftRunActionButtonContract } from '@/lib/api/contracts/tools/agiloft'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { AgiloftSavedSearchResponse } from '@/tools/agiloft/types'
import { buildSavedSearchUrl } from '@/tools/agiloft/utils'
import { parseEwRest } from '@/tools/agiloft/ewrest'
import type { AgiloftRunActionButtonResponse } from '@/tools/agiloft/types'
import { buildRunActionButtonUrl } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('AgiloftSavedSearchAPI')
const logger = createLogger('AgiloftRunActionButtonAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
@@ -21,7 +22,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Agiloft saved_search attempt: ${authResult.error}`)
logger.warn(
`[${requestId}] Unauthorized Agiloft run_action_button attempt: ${authResult.error}`
)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
@@ -29,7 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const parsed = await parseRequest(
agiloftSavedSearchContract,
agiloftRunActionButtonContract,
request,
{},
{
@@ -49,47 +52,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const params = parsed.data.body
const result = await executeAgiloftRequest<AgiloftSavedSearchResponse>(
const result = await executeAgiloftRequest<AgiloftRunActionButtonResponse>(
params,
(base) => ({
url: buildSavedSearchUrl(base, params),
method: 'GET',
url: buildRunActionButtonUrl(base, params),
/** EWActionButton is documented as POST-only. */
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
async (response) => {
const body = await response.text()
const recordId = params.recordId.trim()
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { searches: [] },
error: `Agiloft error: ${response.status} - ${errorText}`,
output: { recordId, callbackId: null },
error: `Agiloft error: ${response.status} - ${body}`,
}
}
const data = (await response.json()) as Record<string, unknown>
const result = (data.result ?? data) as Record<string, unknown>
const searches: Array<{
name: string
label: string
id: string | number
description: string | null
}> = []
if (Array.isArray(result)) {
for (const item of result as Record<string, unknown>[]) {
searches.push({
name: (item.name as string) ?? '',
label: (item.label as string) ?? (item.name as string) ?? '',
id: (item.id as string | number) ?? (item.ID as string | number) ?? '',
description: (item.description as string | null) ?? null,
})
/** Documented response: EWREST_id='82'; EWREST_EWCALLBACK_ID='10100_1'; */
const values = parseEwRest(body)
if (values.size === 0) {
return {
success: false,
output: { recordId, callbackId: null },
error: `Agiloft did not acknowledge the action button: ${body.trim() || '(empty response)'}`,
}
}
return {
success: data.success !== false,
success: true,
output: {
searches,
recordId: values.get('id') ?? recordId,
callbackId: values.get('EWCALLBACK_ID') ?? null,
},
}
}
@@ -97,7 +94,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(result)
} catch (error) {
logger.error(`[${requestId}] Error listing Agiloft saved searches:`, error)
logger.error(`[${requestId}] Error running Agiloft action button:`, error)
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
}
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseEwRest, toSearchRecords } from '@/tools/agiloft/ewrest'
import type { AgiloftSearchResponse } from '@/tools/agiloft/types'
import { buildSearchRecordsUrl } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
@@ -56,66 +57,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'GET',
}),
async (response) => {
const body = await response.text()
const page = params.page ? Number(params.page) : 0
const limit = params.limit ? Number(params.limit) : 0
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { records: [], totalCount: 0, page: 0, limit: 25 },
error: `Agiloft error: ${response.status} - ${errorText}`,
output: { records: [], totalCount: 0, page, limit },
error: `Agiloft error: ${response.status} - ${body}`,
}
}
const data = (await response.json()) as Record<string, unknown>
const records: Record<string, unknown>[] = []
const result = (data.result ?? data) as Record<string, unknown>
if (Array.isArray(result)) {
for (const item of result as Record<string, unknown>[]) {
records.push(item)
}
} else {
const lengthRaw = result.EWREST_length ?? data.EWREST_length
const count = typeof lengthRaw === 'string' ? Number(lengthRaw) : (lengthRaw as number)
if (typeof count === 'number' && Number.isFinite(count)) {
const source = (result.EWREST_length != null ? result : data) as Record<string, unknown>
for (let i = 0; i < count; i++) {
const record: Record<string, unknown> = {}
for (const key of Object.keys(source)) {
const match = key.match(/^EWREST_(.+)_(\d+)$/)
if (match && Number(match[2]) === i) {
record[match[1]] = source[key]
}
}
if (Object.keys(record).length > 0) {
records.push(record)
}
}
/**
* EWSearch answers with EWREST_length plus one EWREST_<field>_<index>
* assignment per field per row, and reports an empty result set as
* EWREST_id_length = '0'. A body with no assignments at all is therefore
* never a legitimate empty search — it is a refusal Agiloft returned
* with HTTP 200, such as an invalid query or an unknown saved search.
*/
const values = parseEwRest(body)
if (values.size === 0) {
return {
success: false,
output: { records: [], totalCount: 0, page, limit },
error: `Agiloft did not return search results: ${body.trim() || '(empty response)'}`,
}
}
const totalCountRaw =
result.totalCount ??
result.total ??
result.count ??
result.EWREST_length ??
data.totalCount ??
data.total ??
data.count ??
data.EWREST_length ??
records.length
const totalCount =
typeof totalCountRaw === 'string' ? Number(totalCountRaw) : (totalCountRaw as number)
const page = params.page ? Number(params.page) : 0
const limit = params.limit ? Number(params.limit) : 25
const { records, count } = toSearchRecords(values)
return {
success: data.success !== false,
output: {
records,
totalCount,
page,
limit,
},
success: true,
output: { records, totalCount: count, page, limit },
}
}
)
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseEwRest, toRecordIds } from '@/tools/agiloft/ewrest'
import type { AgiloftSelectResponse } from '@/tools/agiloft/types'
import { buildSelectRecordsUrl } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
@@ -56,54 +57,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'GET',
}),
async (response) => {
const body = await response.text()
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { recordIds: [], totalCount: 0 },
error: `Agiloft error: ${response.status} - ${errorText}`,
error: `Agiloft error: ${response.status} - ${body}`,
}
}
const data = (await response.json()) as Record<string, unknown>
const result = (data.result ?? data) as Record<string, unknown>
const recordIds: string[] = []
if (Array.isArray(result)) {
for (const item of result as Record<string, unknown>[]) {
const id = item.id ?? item.ID ?? item
recordIds.push(String(id))
}
} else if (typeof result === 'object' && result !== null) {
let i = 0
while (result[`id_${i}`] !== undefined || result[`EWREST_id_${i}`] !== undefined) {
const id = result[`id_${i}`] ?? result[`EWREST_id_${i}`]
recordIds.push(String(id))
i++
}
if (recordIds.length === 0 && result.id !== undefined) {
recordIds.push(String(result.id))
/**
* EWSelect answers with EWREST_id_length followed by one EWREST_id_<n>
* assignment per match, and zero matches still yields the length line.
* A body with no assignments at all is therefore never a legitimate
* empty result — it is a refusal Agiloft returned with HTTP 200, most
* often invalid WHERE-clause SQL.
*/
const values = parseEwRest(body)
if (values.size === 0) {
return {
success: false,
output: { recordIds: [], totalCount: 0 },
error: `Agiloft did not return a result set: ${body.trim() || '(empty response)'}`,
}
}
const totalCountRaw =
result.EWREST_id_length ??
result.totalCount ??
result.total ??
result.count ??
data.EWREST_id_length ??
data.totalCount ??
data.total ??
data.count ??
recordIds.length
const { recordIds, count } = toRecordIds(values)
return {
success: data.success !== false,
output: {
recordIds,
totalCount: Number(totalCountRaw),
},
}
return { success: true, output: { recordIds, totalCount: count } }
}
)
@@ -6,8 +6,9 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
import { buildUpdateRecordUrl } from '@/tools/agiloft/utils'
import { buildUpdateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
export const dynamic = 'force-dynamic'
@@ -49,46 +50,62 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const params = parsed.data.body
let body: string
let fieldValues: Record<string, unknown>
try {
body = JSON.stringify(JSON.parse(params.data))
const parsedData = JSON.parse(params.data)
if (typeof parsedData !== 'object' || parsedData === null || Array.isArray(parsedData)) {
throw new Error('not an object')
}
fieldValues = parsedData as Record<string, unknown>
} catch {
return NextResponse.json({
success: false,
output: { id: null, fields: {} },
error: 'Invalid JSON in data parameter',
error: 'The data parameter must be a JSON object of field names to values',
})
}
const oversized = recordUrlLengthError(params.instanceUrl, (base) =>
buildUpdateRecordUrl(base, params, fieldValues)
)
if (oversized) {
return NextResponse.json({
success: false,
output: { id: null, fields: {} },
error: oversized,
})
}
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
params,
(base) => ({
url: buildUpdateRecordUrl(base, params),
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body,
url: buildUpdateRecordUrl(base, params, fieldValues),
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
async (response) => {
const body = await response.text()
if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { id: null, fields: {} },
error: `Agiloft error: ${response.status} - ${errorText}`,
error: `Agiloft error: ${response.status} - ${body}`,
}
}
const data = (await response.json()) as Record<string, unknown>
const result = (data.result ?? data) as Record<string, unknown>
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
return {
success: data.success !== false,
output: {
id: id != null ? String(id) : null,
fields: result ?? {},
},
/** EWUpdate echoes the whole record back as EWREST_ assignments. */
const values = parseEwRest(body)
if (values.size === 0) {
return {
success: false,
output: { id: null, fields: {} },
error: `Agiloft returned no record data: ${body.trim() || '(empty response)'}`,
}
}
const { id, fields } = toRecord(values)
return { success: true, output: { id, fields } }
}
)
+103 -19
View File
@@ -15,7 +15,7 @@ export const AgiloftBlock: BlockConfig = {
name: 'Agiloft',
description: 'Manage records in Agiloft CLM',
longDescription:
'Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.',
'Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, record locking, and running action buttons across any table in your knowledge base.',
docsLink: 'https://docs.sim.ai/integrations/agiloft',
category: 'tools',
integrationType: IntegrationType.Productivity,
@@ -42,17 +42,18 @@ export const AgiloftBlock: BlockConfig = {
delete_record: [
{ text: 'Delete record', field: 'recordId', core: true },
{ text: 'from', field: 'table' },
{ text: ', handling dependents with', field: 'deleteRule' },
],
search_records: [
{ text: 'Search', field: 'table', core: true },
{ text: 'for', field: 'query' },
{ text: ', using saved search', field: 'search' },
{ text: ', up to', field: 'limit', after: 'records' },
],
select_records: [
{ text: 'Select record IDs from', field: 'table', core: true },
{ text: ', where', field: 'where' },
],
saved_search: [{ text: 'List saved searches on', field: 'table', core: true }],
attach_file: [
{ text: 'Attach', field: ATTACH_FILE_FIELD, core: true },
{ text: 'to record', field: 'recordId', core: true },
@@ -86,6 +87,10 @@ export const AgiloftBlock: BlockConfig = {
{ text: 'Run lock action', field: 'lockAction', core: true },
{ text: 'on record', field: 'recordId', core: true },
],
run_action_button: [
{ text: 'Run action button', field: 'actionButtonField', core: true },
{ text: 'on record', field: 'recordId', core: true },
],
get_choice_line_id: [
{ text: 'Resolve the internal ID of choice', field: 'value', core: true },
{ text: 'on field', field: 'fieldName' },
@@ -107,12 +112,12 @@ export const AgiloftBlock: BlockConfig = {
{ label: 'Delete Record', id: 'delete_record' },
{ label: 'Search Records', id: 'search_records' },
{ label: 'Select Records', id: 'select_records' },
{ label: 'Saved Search', id: 'saved_search' },
{ label: 'Attach File', id: 'attach_file' },
{ label: 'Retrieve Attachment', id: 'retrieve_attachment' },
{ label: 'Remove Attachment', id: 'remove_attachment' },
{ label: 'Attachment Info', id: 'attachment_info' },
{ label: 'Lock Record', id: 'lock_record' },
{ label: 'Run Action Button', id: 'run_action_button' },
{ label: 'Get Choice Line ID', id: 'get_choice_line_id' },
],
value: () => 'search_records',
@@ -169,6 +174,7 @@ export const AgiloftBlock: BlockConfig = {
'remove_attachment',
'attachment_info',
'lock_record',
'run_action_button',
],
},
required: {
@@ -182,6 +188,7 @@ export const AgiloftBlock: BlockConfig = {
'remove_attachment',
'attachment_info',
'lock_record',
'run_action_button',
],
},
},
@@ -203,15 +210,21 @@ export const AgiloftBlock: BlockConfig = {
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: "status='Active' AND company_name~='Acme'",
placeholder: "status='Active'&&company_name~='Acme'",
condition: { field: 'operation', value: 'search_records' },
required: { field: 'operation', value: 'search_records' },
wandConfig: {
enabled: true,
prompt:
"Generate an Agiloft search query. Use field_name='value' for exact match, field_name~='value' for contains, and AND/OR for combining conditions. Return ONLY the query string - no explanations, no extra text.",
"Generate an Agiloft EWSearch query. Use field_name='value' for exact match, field_name~='value' for contains, != for not equals, and <, <=, >, >= for comparisons. Combine conditions with && for and, || for or. Quote every value in single quotes, and quote field labels that contain spaces. Return ONLY the query string - no explanations, no extra text.",
},
},
{
id: 'search',
title: 'Saved Search',
type: 'short-input',
placeholder: 'e.g., C: Status is Closed',
condition: { field: 'operation', value: 'search_records' },
},
{
id: 'where',
title: 'WHERE Clause',
@@ -222,7 +235,7 @@ export const AgiloftBlock: BlockConfig = {
wandConfig: {
enabled: true,
prompt:
"Generate a SQL WHERE clause for an Agiloft EWSelect query using database column names. Use standard SQL syntax (e.g., column='value', column like '%text%'). Return ONLY the WHERE clause - no explanations, no extra text.",
"Generate a SQL WHERE clause for an Agiloft EWSelect query using database column names. Use standard SQL syntax (e.g., column='value', column like '%text%'). EWSelect has no page size, so append a database limit such as \"limit 0,200\" to keep the result bounded. Return ONLY the WHERE clause - no explanations, no extra text.",
},
},
{
@@ -251,6 +264,14 @@ export const AgiloftBlock: BlockConfig = {
],
},
},
{
id: 'actionButtonField',
title: 'Action Button Field',
type: 'short-input',
placeholder: 'e.g., ab_send_for_signature',
condition: { field: 'operation', value: 'run_action_button' },
required: { field: 'operation', value: 'run_action_button' },
},
{
id: 'value',
title: 'Choice Value',
@@ -315,6 +336,36 @@ export const AgiloftBlock: BlockConfig = {
condition: { field: 'operation', value: 'lock_record' },
required: { field: 'operation', value: 'lock_record' },
},
{
id: 'deleteRule',
title: 'Dependent Records',
type: 'dropdown',
options: [
{ label: 'Fail if dependents exist', id: 'ERROR_IF_DEPENDANTS' },
{ label: 'Delete dependents where possible', id: 'APPLY_DELETE_WHERE_POSSIBLE' },
{ label: 'Delete, otherwise unlink', id: 'DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK' },
{ label: 'Unlink dependents', id: 'APPLY_UNLINK' },
{ label: 'Unlink, otherwise delete', id: 'UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE' },
],
value: () => 'ERROR_IF_DEPENDANTS',
condition: { field: 'operation', value: 'delete_record' },
},
{
id: 'force',
title: 'Force Unlock',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
mode: 'advanced',
condition: {
field: 'operation',
value: 'lock_record',
and: { field: 'lockAction', value: 'unlock' },
},
},
{
id: 'fields',
title: 'Fields',
@@ -357,6 +408,8 @@ export const AgiloftBlock: BlockConfig = {
'agiloft_read_record',
'agiloft_remove_attachment',
'agiloft_retrieve_attachment',
'agiloft_run_action_button',
// Retired, but retained so blocks saved with operation='saved_search' still resolve.
'agiloft_saved_search',
'agiloft_search_records',
'agiloft_select_records',
@@ -371,6 +424,9 @@ export const AgiloftBlock: BlockConfig = {
if (normalizedFile) {
params.file = normalizedFile
}
if (params.force !== undefined) {
params.force = params.force === true || params.force === 'true'
}
return params
},
},
@@ -385,14 +441,24 @@ export const AgiloftBlock: BlockConfig = {
table: { type: 'string', description: 'Table name' },
recordId: { type: 'string', description: 'Record ID' },
data: { type: 'string', description: 'Record data as JSON' },
query: { type: 'string', description: 'Search query' },
query: { type: 'string', description: 'Ad hoc EWSearch query' },
search: { type: 'string', description: 'Label of a saved search defined on the table' },
where: { type: 'string', description: 'SQL WHERE clause for select' },
fieldName: { type: 'string', description: 'Attachment field name or choice field name' },
value: { type: 'string', description: 'Choice value to resolve to its line ID' },
actionButtonField: {
type: 'string',
description: 'Logical name of the field holding the action button to run',
},
attachFile: { type: 'file', description: 'File to attach' },
fileName: { type: 'string', description: 'Name for the attached file' },
position: { type: 'string', description: 'Attachment position index' },
lockAction: { type: 'string', description: 'Lock action (lock, unlock, check)' },
force: { type: 'boolean', description: 'Force an unlock held by another user (admins only)' },
deleteRule: {
type: 'string',
description: 'How EWDelete treats records that depend on the one being deleted',
},
fields: { type: 'string', description: 'Fields to return' },
page: { type: 'string', description: 'Page number' },
limit: { type: 'string', description: 'Results per page' },
@@ -427,7 +493,8 @@ export const AgiloftBlock: BlockConfig = {
},
totalCount: {
type: 'number',
description: 'Total number of matching results',
description:
'Number of matching results. For a paginated search this counts the current page only',
condition: {
field: 'operation',
value: ['search_records', 'select_records', 'attachment_info'],
@@ -435,12 +502,12 @@ export const AgiloftBlock: BlockConfig = {
},
page: {
type: 'number',
description: 'Current page number',
description: 'Page number that was requested (0-based)',
condition: { field: 'operation', value: 'search_records' },
},
limit: {
type: 'number',
description: 'Results per page',
description: 'Page size that was requested; 0 when Agiloft chose the page size',
condition: { field: 'operation', value: 'search_records' },
},
recordIds: {
@@ -448,11 +515,6 @@ export const AgiloftBlock: BlockConfig = {
description: 'Array of record IDs matching the WHERE clause',
condition: { field: 'operation', value: 'select_records' },
},
searches: {
type: 'json',
description: 'Array of saved search definitions (name, label, id, description)',
condition: { field: 'operation', value: 'saved_search' },
},
file: {
type: 'file',
description: 'Downloaded attachment file',
@@ -465,8 +527,11 @@ export const AgiloftBlock: BlockConfig = {
},
recordId: {
type: 'string',
description: 'ID of the record the file operation was performed on',
condition: { field: 'operation', value: ['attach_file', 'remove_attachment'] },
description: 'ID of the record the operation was performed on',
condition: {
field: 'operation',
value: ['attach_file', 'remove_attachment', 'run_action_button'],
},
},
fieldName: {
type: 'string',
@@ -490,7 +555,7 @@ export const AgiloftBlock: BlockConfig = {
},
lockStatus: {
type: 'string',
description: 'Lock status (e.g., LOCKED, UNLOCKED)',
description: 'Lock status: LOCKED when the record is held, NO_LOCK when it is free',
condition: { field: 'operation', value: 'lock_record' },
},
lockedBy: {
@@ -503,6 +568,11 @@ export const AgiloftBlock: BlockConfig = {
description: 'Minutes until the lock expires',
condition: { field: 'operation', value: 'lock_record' },
},
callbackId: {
type: 'string',
description: 'Callback identifier Agiloft returns for the asynchronous action-button run',
condition: { field: 'operation', value: 'run_action_button' },
},
choiceLineId: {
type: 'number',
description: 'Internal numeric ID of the resolved choice value',
@@ -606,5 +676,19 @@ export const AgiloftBlockMeta = {
content:
'# Summarize Contract Terms\n\nTurn an Agiloft contract record into a concise brief.\n\n## Steps\n1. Read the contract record and its key fields and attached terms.\n2. Identify obligations, payment terms, renewal/termination clauses, and critical dates.\n3. Note any unusual or high-risk terms.\n\n## Output\nA short brief: parties, term, value, key obligations, critical dates, and any risk flags. Keep it readable for non-lawyers.',
},
{
name: 'collect-executed-contract-documents',
description:
'Pull the signed documents attached to Agiloft contract records so they can be archived or reviewed elsewhere.',
content:
'# Collect Executed Contract Documents\n\nGather the executed files attached to Agiloft contract records.\n\n## Steps\n1. Identify the contract records in scope, by saved search or by an ad hoc query.\n2. For each record, list the attachments on the document field to see what is present and how large each file is.\n3. Download the attachment at the position that holds the executed copy.\n\n## Output\nOne entry per contract: record ID, counterparty, file name, and size. Call out any record where the document field is empty or holds an unexpected number of files.',
},
{
name: 'safely-edit-a-locked-record',
description:
'Check and manage the record lock on an Agiloft record before and after an automated edit.',
content:
"# Safely Edit a Locked Record\n\nAvoid clobbering another user's in-progress edit when a workflow updates an Agiloft record.\n\n## Steps\n1. Check the lock status on the record before writing. If it reports LOCKED, report who holds it and stop rather than overwriting.\n2. When the record is free, take the lock, apply the update, then release it.\n3. Locks expire on their own, so keep the locked window as short as the update needs.\n\n## Output\nState what the lock status was, whether the update was applied or deferred, and confirm the lock was released.",
},
],
} as const satisfies BlockMeta
+68 -37
View File
@@ -160,9 +160,23 @@ export type AgiloftUpdateRecordBody = ContractBody<typeof agiloftUpdateRecordCon
export type AgiloftUpdateRecordBodyInput = ContractBodyInput<typeof agiloftUpdateRecordContract>
export type AgiloftUpdateRecordResponse = ContractJsonResponse<typeof agiloftUpdateRecordContract>
/**
* EWDelete requires a delete rule naming how dependent records are handled.
* `REPLACE_WITH_ANOTHER` is deliberately excluded — it additionally needs a
* `subs` list of substitute record IDs, which this tool does not model.
*/
export const agiloftDeleteRecordBodySchema = z.object({
...agiloftBaseFields,
recordId: z.string().min(1, 'Record ID is required'),
deleteRule: z
.enum([
'ERROR_IF_DEPENDANTS',
'APPLY_DELETE_WHERE_POSSIBLE',
'DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK',
'APPLY_UNLINK',
'UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE',
])
.default('ERROR_IF_DEPENDANTS'),
})
export const agiloftDeleteRecordResponseSchema = z.object({
@@ -191,6 +205,7 @@ export const agiloftLockRecordBodySchema = z.object({
lockAction: z.enum(['lock', 'unlock', 'check'], {
message: 'Lock action must be "lock", "unlock", or "check"',
}),
force: z.boolean().optional(),
})
export const agiloftLockRecordResponseSchema = z.object({
@@ -215,13 +230,29 @@ export type AgiloftLockRecordBody = ContractBody<typeof agiloftLockRecordContrac
export type AgiloftLockRecordBodyInput = ContractBodyInput<typeof agiloftLockRecordContract>
export type AgiloftLockRecordResponse = ContractJsonResponse<typeof agiloftLockRecordContract>
export const agiloftSearchRecordsBodySchema = z.object({
...agiloftBaseFields,
query: z.string().min(1, 'Query is required'),
fields: z.string().optional(),
page: z.string().optional(),
limit: z.string().optional(),
})
/**
* EWSearch accepts a saved-search label (`search`), an ad hoc `query`, or both.
* At least one must be present, otherwise the call degenerates into an
* unbounded scan of the whole table.
*/
export const agiloftSearchRecordsBodySchema = z
.object({
...agiloftBaseFields,
query: z.string().optional(),
search: z.string().optional(),
fields: z.string().optional(),
page: z.string().optional(),
limit: z.string().optional(),
})
.superRefine((value, ctx) => {
if (!value.query?.trim() && !value.search?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['query'],
message: 'Provide a search query or a saved search name',
})
}
})
export const agiloftSearchRecordsResponseSchema = z.object({
success: z.boolean(),
@@ -270,36 +301,6 @@ export type AgiloftSelectRecordsBody = ContractBody<typeof agiloftSelectRecordsC
export type AgiloftSelectRecordsBodyInput = ContractBodyInput<typeof agiloftSelectRecordsContract>
export type AgiloftSelectRecordsResponse = ContractJsonResponse<typeof agiloftSelectRecordsContract>
export const agiloftSavedSearchBodySchema = z.object({
...agiloftBaseFields,
})
export const agiloftSavedSearchResponseSchema = z.object({
success: z.boolean(),
output: z.object({
searches: z.array(
z.object({
name: z.string(),
label: z.string(),
id: z.union([z.string(), z.number()]),
description: z.string().nullable(),
})
),
}),
error: z.string().optional(),
})
export const agiloftSavedSearchContract = defineRouteContract({
method: 'POST',
path: '/api/tools/agiloft/saved_search',
body: agiloftSavedSearchBodySchema,
response: { mode: 'json', schema: agiloftSavedSearchResponseSchema },
})
export type AgiloftSavedSearchBody = ContractBody<typeof agiloftSavedSearchContract>
export type AgiloftSavedSearchBodyInput = ContractBodyInput<typeof agiloftSavedSearchContract>
export type AgiloftSavedSearchResponse = ContractJsonResponse<typeof agiloftSavedSearchContract>
export const agiloftAttachmentInfoBodySchema = z.object({
...agiloftBaseFields,
recordId: z.string().min(1, 'Record ID is required'),
@@ -394,3 +395,33 @@ export type AgiloftGetChoiceLineIdBodyInput = ContractBodyInput<
export type AgiloftGetChoiceLineIdResponse = ContractJsonResponse<
typeof agiloftGetChoiceLineIdContract
>
export const agiloftRunActionButtonBodySchema = z.object({
...agiloftBaseFields,
recordId: z.string().min(1, 'Record ID is required'),
actionButtonField: z.string().min(1, 'Action button field name is required'),
})
export const agiloftRunActionButtonResponseSchema = z.object({
success: z.boolean(),
output: z.object({
recordId: z.string(),
callbackId: z.string().nullable(),
}),
error: z.string().optional(),
})
export const agiloftRunActionButtonContract = defineRouteContract({
method: 'POST',
path: '/api/tools/agiloft/run_action_button',
body: agiloftRunActionButtonBodySchema,
response: { mode: 'json', schema: agiloftRunActionButtonResponseSchema },
})
export type AgiloftRunActionButtonBody = ContractBody<typeof agiloftRunActionButtonContract>
export type AgiloftRunActionButtonBodyInput = ContractBodyInput<
typeof agiloftRunActionButtonContract
>
export type AgiloftRunActionButtonResponse = ContractJsonResponse<
typeof agiloftRunActionButtonContract
>
+5 -5
View File
@@ -275,7 +275,7 @@
"slug": "agiloft",
"name": "Agiloft",
"description": "Manage records in Agiloft CLM",
"longDescription": "Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.",
"longDescription": "Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, record locking, and running action buttons across any table in your knowledge base.",
"bgColor": "#001028",
"iconName": "AgiloftIcon",
"docsUrl": "https://docs.sim.ai/integrations/agiloft",
@@ -304,10 +304,6 @@
"name": "Select Records",
"description": "Select record IDs matching a SQL WHERE clause from an Agiloft table."
},
{
"name": "Saved Search",
"description": "List saved searches defined for an Agiloft table."
},
{
"name": "Attach File",
"description": "Attach a file to a field in an Agiloft record."
@@ -328,6 +324,10 @@
"name": "Lock Record",
"description": "Lock, unlock, or check the lock status of an Agiloft record."
},
{
"name": "Run Action Button",
"description": "Run an action button on an Agiloft record, such as an approval or send-for-signature step."
},
{
"name": "Get Choice Line ID",
"description": "Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields."
+8
View File
@@ -45,6 +45,13 @@ export const agiloftDeleteRecordTool: ToolConfig<AgiloftDeleteRecordParams, Agil
visibility: 'user-or-llm',
description: 'ID of the record to delete',
},
deleteRule: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, or UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE',
},
},
request: {
@@ -58,6 +65,7 @@ export const agiloftDeleteRecordTool: ToolConfig<AgiloftDeleteRecordParams, Agil
password: params.password,
table: params.table,
recordId: params.recordId,
deleteRule: params.deleteRule,
}),
},
+145
View File
@@ -0,0 +1,145 @@
/**
* @vitest-environment node
*
* Every fixture below is quoted from Agiloft's own REST documentation so the
* parser is pinned to the published response format rather than to a shape we
* assumed.
*/
import { describe, expect, it } from 'vitest'
import {
isEwRestBody,
parseEwRest,
toRecord,
toRecordIds,
toSearchRecords,
} from '@/tools/agiloft/ewrest'
/** REST - Create: "A result similar to the following will be returned". */
const CREATE_BODY = "EWREST_id='353';"
/** REST - Read: the documented result for record 358 of contacts.employees. */
const READ_BODY = `EWREST_full_name='John Doe';
EWREST_first_name='John';
EWREST__1576_company_name0='IBM';
EWREST_f_group_0='Service Manager';
EWREST_id='358';
EWREST__login='jdoe';
EWREST_date_updated='Dec 27 2017 04:40:24';
EWREST_last_name='Doe';`
/** REST - Select, Example 1: three matching records. */
const SELECT_BODY = `EWREST_id_length = '3';
EWREST_id_0 = '150';
EWREST_id_1 = '169';
EWREST_id_2 = '325';`
/** REST - Select: the documented empty result. */
const SELECT_EMPTY_BODY = "EWREST_id_length = '0';"
/** REST - Search, Example 2: two requested fields across four records. */
const SEARCH_BODY = `EWREST_length = '4';
EWREST_summary_0='Here is a new service request with some tasks';
EWREST_priority_0='High';
EWREST_summary_1='New Employee Setup for Patricia Smith';
EWREST_priority_1='High';
EWREST_summary_2='Upgrading Our Software';
EWREST_priority_2='High';
EWREST_summary_3='Need New Wireless Card for Laptop';
EWREST_priority_3='High';`
describe('parseEwRest', () => {
it('reads the field assignments EWRead returns', () => {
const values = parseEwRest(READ_BODY)
expect(values.get('id')).toBe('358')
expect(values.get('full_name')).toBe('John Doe')
expect(values.get('date_updated')).toBe('Dec 27 2017 04:40:24')
expect(values.get('_login')).toBe('jdoe')
})
it('tolerates the spaces around = that Select and Search use', () => {
expect(parseEwRest(SELECT_BODY).get('id_length')).toBe('3')
})
it('ignores blank lines and non-assignment noise instead of aborting', () => {
const values = parseEwRest(`\n${CREATE_BODY}\nnot an assignment\n\n`)
expect(values.size).toBe(1)
expect(values.get('id')).toBe('353')
})
it('reports a plain-text error body as not being an EWREST response', () => {
expect(isEwRestBody('Error executing query, please consult logs')).toBe(false)
expect(isEwRestBody(CREATE_BODY)).toBe(true)
})
})
describe('toRecord', () => {
it('surfaces the id while keeping it among the fields', () => {
const { id, fields } = toRecord(parseEwRest(READ_BODY))
expect(id).toBe('358')
expect(fields.first_name).toBe('John')
expect(fields.id).toBe('358')
})
it('reports a missing id as null rather than inventing one', () => {
expect(toRecord(parseEwRest("EWREST_summary='no id here';")).id).toBeNull()
})
})
describe('toRecordIds', () => {
it('reads the documented EWSelect result', () => {
expect(toRecordIds(parseEwRest(SELECT_BODY))).toEqual({
recordIds: ['150', '169', '325'],
count: 3,
})
})
it('reads the documented empty EWSelect result', () => {
expect(toRecordIds(parseEwRest(SELECT_EMPTY_BODY))).toEqual({ recordIds: [], count: 0 })
})
})
describe('toSearchRecords', () => {
it('regroups the flat field_index assignments into one object per row', () => {
const { records, count } = toSearchRecords(parseEwRest(SEARCH_BODY))
expect(count).toBe(4)
expect(records).toHaveLength(4)
expect(records[0]).toEqual({
summary: 'Here is a new service request with some tasks',
priority: 'High',
})
expect(records[3].summary).toBe('Need New Wireless Card for Laptop')
})
it('keeps rows in index order regardless of assignment order', () => {
const shuffled = `EWREST_length = '2';
EWREST_name_1='second';
EWREST_name_0='first';`
expect(toSearchRecords(parseEwRest(shuffled)).records.map((r) => r.name)).toEqual([
'first',
'second',
])
})
it('does not mistake the length line for a record field', () => {
const { records } = toSearchRecords(parseEwRest(SEARCH_BODY))
for (const record of records) {
expect(record).not.toHaveProperty('length')
}
})
it('preserves fields whose own names end in a number', () => {
/** EWRead's documented sample includes _1576_company_name0. */
const body = `EWREST_length = '1';
EWREST__1576_company_name0_0='IBM';`
expect(toSearchRecords(parseEwRest(body)).records[0]).toEqual({
_1576_company_name0: 'IBM',
})
})
})
+130
View File
@@ -0,0 +1,130 @@
/**
* Parser for Agiloft's `EWREST_` response format.
*
* Every `/ewws/EW*` operation answers with a body of JavaScript assignments
* rather than JSON — the interface was designed to be `eval`-ed by a browser
* client. The documented shapes are:
*
* EWCreate -> EWREST_id='353';
* EWRead -> EWREST_full_name='John Doe';
* EWREST_id='358';
* EWSelect -> EWREST_id_length = '3';
* EWREST_id_0 = '150';
* EWSearch -> EWREST_length = '4';
* EWREST_summary_0='Upgrading Our Software';
*
* Note the inconsistent spacing around `=`: the record-field forms have none,
* the `_length` and indexed-id forms in the Select/Search examples do. Both are
* accepted here.
*/
const ASSIGNMENT = /^EWREST_(?<key>[^=\s]+)\s*=\s*'(?<value>[\s\S]*)';?$/
/**
* Parses a body into its raw `EWREST_` key/value pairs, preserving document
* order. Lines that are blank or do not match the assignment form are skipped,
* so a trailing newline or an incidental banner does not abort the parse.
*/
export function parseEwRest(body: string): Map<string, string> {
const values = new Map<string, string>()
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
const match = ASSIGNMENT.exec(line)
const key = match?.groups?.key
if (!key) continue
values.set(key, match.groups?.value ?? '')
}
return values
}
/**
* True when the body carries at least one `EWREST_` assignment. Agiloft answers
* some failures with HTTP 200 and a plain-text error, so callers use this to
* tell "no data" apart from "not an EWREST response at all".
*/
export function isEwRestBody(body: string): boolean {
return parseEwRest(body).size > 0
}
/**
* Splits a parsed record into its ID and the remaining field values. EWRead and
* EWUpdate both return the whole record with `id` among the fields.
*/
export function toRecord(values: Map<string, string>): {
id: string | null
fields: Record<string, string>
} {
const fields: Record<string, string> = {}
for (const [key, value] of values) {
fields[key] = value
}
return { id: fields.id ?? null, fields }
}
/**
* Regroups the flat `EWREST_<field>_<index>` assignments EWSearch returns into
* one object per record. `EWREST_length` gives the row count for the current
* page; when it is absent the highest observed index is used instead so a
* partial body still yields the rows it did contain.
*/
export function toSearchRecords(values: Map<string, string>): {
records: Record<string, string>[]
count: number
} {
const INDEXED = /^(?<field>.+)_(?<index>\d+)$/
const byIndex = new Map<number, Record<string, string>>()
for (const [key, value] of values) {
if (key === 'length' || key === 'id_length') continue
const match = INDEXED.exec(key)
const field = match?.groups?.field
if (!field) continue
const index = Number(match.groups?.index)
let record = byIndex.get(index)
if (!record) {
record = {}
byIndex.set(index, record)
}
record[field] = value
}
const declared = Number(values.get('length') ?? values.get('id_length'))
const count = Number.isFinite(declared) ? declared : byIndex.size
const records: Record<string, string>[] = []
for (const index of [...byIndex.keys()].sort((a, b) => a - b)) {
records.push(byIndex.get(index) as Record<string, string>)
}
return { records, count }
}
/**
* Reads the `EWREST_id_length` / `EWREST_id_<n>` pairs EWSelect returns. A
* result of zero records is reported as `EWREST_id_length = '0';` with no
* indexed entries.
*/
export function toRecordIds(values: Map<string, string>): {
recordIds: string[]
count: number
} {
const recordIds: string[] = []
for (let index = 0; ; index++) {
const id = values.get(`id_${index}`)
if (id === undefined) break
recordIds.push(id)
}
const declared = Number(values.get('id_length'))
return {
recordIds,
count: Number.isFinite(declared) ? declared : recordIds.length,
}
}
+1
View File
@@ -7,6 +7,7 @@ export { agiloftLockRecordTool } from '@/tools/agiloft/lock_record'
export { agiloftReadRecordTool } from '@/tools/agiloft/read_record'
export { agiloftRemoveAttachmentTool } from '@/tools/agiloft/remove_attachment'
export { agiloftRetrieveAttachmentTool } from '@/tools/agiloft/retrieve_attachment'
export { agiloftRunActionButtonTool } from '@/tools/agiloft/run_action_button'
export { agiloftSavedSearchTool } from '@/tools/agiloft/saved_search'
export { agiloftSearchRecordsTool } from '@/tools/agiloft/search_records'
export { agiloftSelectRecordsTool } from '@/tools/agiloft/select_records'
+9 -1
View File
@@ -50,6 +50,13 @@ export const agiloftLockRecordTool: ToolConfig<AgiloftLockRecordParams, AgiloftL
visibility: 'user-or-llm',
description: 'Action to perform: "lock", "unlock", or "check"',
},
force: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'Unlock only: release a lock held by another user. Requires membership in the admin group.',
},
},
request: {
@@ -64,6 +71,7 @@ export const agiloftLockRecordTool: ToolConfig<AgiloftLockRecordParams, AgiloftL
table: params.table,
recordId: params.recordId,
lockAction: params.lockAction,
force: params.force,
}),
},
@@ -83,7 +91,7 @@ export const agiloftLockRecordTool: ToolConfig<AgiloftLockRecordParams, AgiloftL
},
lockStatus: {
type: 'string',
description: 'Lock status (e.g., "LOCKED", "UNLOCKED")',
description: 'Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free',
},
lockedBy: {
type: 'string',
@@ -0,0 +1,97 @@
import type {
AgiloftRunActionButtonParams,
AgiloftRunActionButtonResponse,
} from '@/tools/agiloft/types'
import type { ToolConfig } from '@/tools/types'
export const agiloftRunActionButtonTool: ToolConfig<
AgiloftRunActionButtonParams,
AgiloftRunActionButtonResponse
> = {
id: 'agiloft_run_action_button',
name: 'Agiloft Run Action Button',
description:
'Run an action button on an Agiloft record, such as an approval or send-for-signature step.',
version: '1.0.0',
params: {
instanceUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
},
knowledgeBase: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Knowledge base name',
},
login: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Agiloft username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Agiloft password',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Table name (e.g., "contracts", "case")',
},
recordId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the record to run the action button on',
},
actionButtonField: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Logical name of the field holding the action button (e.g., "ab_field")',
},
},
request: {
url: () => '/api/tools/agiloft/run_action_button',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
instanceUrl: params.instanceUrl,
knowledgeBase: params.knowledgeBase,
login: params.login,
password: params.password,
table: params.table,
recordId: params.recordId,
actionButtonField: params.actionButtonField,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: data.success ?? true,
output: data.output,
...(data.error ? { error: data.error } : {}),
}
},
outputs: {
recordId: {
type: 'string',
description: 'ID of the record the action button was run on',
},
callbackId: {
type: 'string',
description:
'Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID',
},
},
}
@@ -0,0 +1,20 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { agiloftSavedSearchTool } from '@/tools/agiloft/saved_search'
import toolIds from '@/tools/generated/tool-ids'
describe('retired agiloft_saved_search', () => {
it('keeps its id registered so workflows saved with that operation still resolve a tool', () => {
expect(toolIds).toContain('agiloft_saved_search')
expect(agiloftSavedSearchTool.id).toBe('agiloft_saved_search')
})
it('fails with a migration hint instead of calling an undocumented endpoint', async () => {
const result = await agiloftSavedSearchTool.directExecution?.({})
expect(result?.success).toBe(false)
expect(result?.error).toContain('Search Records')
})
})
+40 -39
View File
@@ -1,87 +1,88 @@
import type { AgiloftSavedSearchParams, AgiloftSavedSearchResponse } from '@/tools/agiloft/types'
import type { ToolConfig } from '@/tools/types'
/**
* Retired operation, kept registered so workflows saved while it was offered
* still resolve a tool instead of failing with "Tool not found".
*
* `EWSavedSearch` appears in Agiloft's Scope Parameter operation list, so the
* endpoint exists, but it has no documentation page — neither its URL
* parameters nor its response shape can be verified. The previous
* implementation guessed both and could only ever report an empty list, which
* reads as "this table has no saved searches". Running a saved search is now
* supported for real through the Search Records operation's Saved Search
* field, so this fails fast and points there rather than issuing a request
* whose behavior nobody can predict.
*/
export const agiloftSavedSearchTool: ToolConfig<
AgiloftSavedSearchParams,
AgiloftSavedSearchResponse
> = {
id: 'agiloft_saved_search',
name: 'Agiloft Saved Search',
description: 'List saved searches defined for an Agiloft table.',
name: 'Agiloft Saved Search (retired)',
description:
'Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.',
version: '1.0.0',
params: {
instanceUrl: {
type: 'string',
required: true,
required: false,
visibility: 'user-only',
description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
description: 'Agiloft instance URL',
},
knowledgeBase: {
type: 'string',
required: true,
required: false,
visibility: 'user-only',
description: 'Knowledge base name',
},
login: {
type: 'string',
required: true,
required: false,
visibility: 'user-only',
description: 'Agiloft username',
},
password: {
type: 'string',
required: true,
required: false,
visibility: 'user-only',
description: 'Agiloft password',
},
table: {
type: 'string',
required: true,
required: false,
visibility: 'user-or-llm',
description: 'Table name to list saved searches for (e.g., "contracts")',
description: 'Table name',
},
},
/** Fails without a network call — there is no endpoint we can correctly call. */
directExecution: async () => ({
success: false,
output: { searches: [] },
error:
'The Agiloft "Saved Search" operation has been retired because Agiloft does not document an endpoint for listing saved searches. Switch this block to the "Search Records" operation and enter the saved search name in its Saved Search field.',
}),
request: {
url: () => '/api/tools/agiloft/saved_search',
url: () => '/api/tools/agiloft/search_records',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
instanceUrl: params.instanceUrl,
knowledgeBase: params.knowledgeBase,
login: params.login,
password: params.password,
table: params.table,
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: data.success ?? true,
output: data.output,
...(data.error ? { error: data.error } : {}),
}
},
transformResponse: async () => ({
success: false,
output: { searches: [] },
error: 'The Agiloft "Saved Search" operation has been retired.',
}),
outputs: {
searches: {
type: 'array',
description: 'List of saved searches for the table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Saved search name' },
label: { type: 'string', description: 'Saved search display label' },
id: { type: 'number', description: 'Saved search database identifier' },
description: {
type: 'string',
description: 'Saved search description',
optional: true,
},
},
},
description: 'Always empty; this operation is retired',
items: { type: 'object' },
},
},
}
+16 -6
View File
@@ -43,10 +43,17 @@ export const agiloftSearchRecordsTool: ToolConfig<
},
query: {
type: 'string',
required: true,
required: false,
visibility: 'user-or-llm',
description:
'Search query using Agiloft query syntax (e.g., "status=\'Active\'" or "company_name~=\'Acme\'")',
"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \"summary~='test'&&priority='High'\". Required unless a saved search is given.",
},
search: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Label of a saved search defined on the table (e.g., "C: Status is Closed"). Can be combined with a query to narrow it further.',
},
fields: {
type: 'string',
@@ -64,7 +71,8 @@ export const agiloftSearchRecordsTool: ToolConfig<
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of records to return per page',
description:
'Maximum number of records to return per page. Agiloft treats 0 as "all records", so leave it unset or use a positive value to keep result sizes bounded.',
},
},
@@ -79,6 +87,7 @@ export const agiloftSearchRecordsTool: ToolConfig<
password: params.password,
table: params.table,
query: params.query,
search: params.search,
fields: params.fields,
page: params.page,
limit: params.limit,
@@ -101,15 +110,16 @@ export const agiloftSearchRecordsTool: ToolConfig<
},
totalCount: {
type: 'number',
description: 'Total number of matching records',
description:
'Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set.',
},
page: {
type: 'number',
description: 'Current page number',
description: 'Page number that was requested (0-based)',
},
limit: {
type: 'number',
description: 'Records per page',
description: 'Page size that was requested; 0 when no limit was sent and Agiloft chose one',
},
},
}
+1 -1
View File
@@ -46,7 +46,7 @@ export const agiloftSelectRecordsTool: ToolConfig<
required: true,
visibility: 'user-or-llm',
description:
'SQL WHERE clause using database column names (e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'")',
'SQL WHERE clause using database column names (e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"). EWSelect has no page size and returns every matching ID, so append a database limit such as "limit 0,200" to bound the result.',
},
},
+32 -14
View File
@@ -26,12 +26,22 @@ export interface AgiloftUpdateRecordParams extends AgiloftBaseParams {
data: string
}
/** Strategies EWDelete accepts for records that depend on the one being deleted. */
export type AgiloftDeleteRule =
| 'ERROR_IF_DEPENDANTS'
| 'APPLY_DELETE_WHERE_POSSIBLE'
| 'DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK'
| 'APPLY_UNLINK'
| 'UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE'
export interface AgiloftDeleteRecordParams extends AgiloftBaseParams {
recordId: string
deleteRule?: AgiloftDeleteRule
}
export interface AgiloftSearchRecordsParams extends AgiloftBaseParams {
query: string
query?: string
search?: string
fields?: string
page?: string
limit?: string
@@ -41,8 +51,6 @@ export interface AgiloftSelectRecordsParams extends AgiloftBaseParams {
where: string
}
export type AgiloftSavedSearchParams = AgiloftBaseParams
export interface AgiloftAttachmentInfoParams extends AgiloftBaseParams {
recordId: string
fieldName: string
@@ -51,6 +59,7 @@ export interface AgiloftAttachmentInfoParams extends AgiloftBaseParams {
export interface AgiloftLockRecordParams extends AgiloftBaseParams {
recordId: string
lockAction: 'lock' | 'unlock' | 'check'
force?: boolean
}
export interface AgiloftRecordResponse extends ToolResponse {
@@ -83,17 +92,6 @@ export interface AgiloftSelectResponse extends ToolResponse {
}
}
export interface AgiloftSavedSearchResponse extends ToolResponse {
output: {
searches: Array<{
name: string
label: string
id: string | number
description: string | null
}>
}
}
export interface AgiloftAttachmentInfoResponse extends ToolResponse {
output: {
attachments: Array<{
@@ -171,3 +169,23 @@ export interface AgiloftGetChoiceLineIdResponse extends ToolResponse {
choiceLineId: number | null
}
}
export interface AgiloftRunActionButtonParams extends AgiloftBaseParams {
recordId: string
actionButtonField: string
}
export interface AgiloftRunActionButtonResponse extends ToolResponse {
output: {
recordId: string
callbackId: string | null
}
}
export type AgiloftSavedSearchParams = Partial<AgiloftBaseParams>
export interface AgiloftSavedSearchResponse extends ToolResponse {
output: {
searches: unknown[]
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildCreateRecordUrl,
buildDeleteRecordUrl,
buildLockRecordUrl,
buildReadRecordUrl,
buildRunActionButtonUrl,
buildSearchRecordsUrl,
buildUpdateRecordUrl,
recordUrlLengthError,
} from '@/tools/agiloft/utils'
const BASE = 'https://example.agiloft.com'
const baseParams = {
instanceUrl: BASE,
knowledgeBase: 'Demo',
login: 'admin',
password: 'secret',
table: 'helpdesk_case',
}
describe('CRUD endpoints', () => {
it('targets the documented EW* operations rather than the /ewws/REST path', () => {
expect(buildCreateRecordUrl(BASE, baseParams, {})).toContain('/ewws/EWCreate?')
expect(buildReadRecordUrl(BASE, { ...baseParams, recordId: '358' })).toContain('/ewws/EWRead?')
expect(buildUpdateRecordUrl(BASE, { ...baseParams, recordId: '358' }, {})).toContain(
'/ewws/EWUpdate?'
)
expect(buildDeleteRecordUrl(BASE, { ...baseParams, recordId: '358' })).toContain(
'/ewws/EWDelete?'
)
})
it('passes record data as the &field=value pairs EWCreate reads off the query string', () => {
const url = buildCreateRecordUrl(BASE, baseParams, {
first_name: 'John',
last_name: 'Doe',
})
expect(url).toContain('&first_name=John')
expect(url).toContain('&last_name=Doe')
})
it('percent-encodes field names and values so separators cannot be injected', () => {
const url = buildCreateRecordUrl(BASE, baseParams, {
summary: "Acme & Co: 100% 'done'",
})
expect(url).toContain("&summary=Acme%20%26%20Co%3A%20100%25%20'done'")
expect(url).not.toContain('Acme & Co')
})
it('skips null and undefined field values instead of sending the literal text', () => {
const url = buildCreateRecordUrl(BASE, baseParams, {
keep: 'yes',
skipNull: null,
skipUndefined: undefined,
})
expect(url).toContain('&keep=yes')
expect(url).not.toContain('skipNull')
expect(url).not.toContain('skipUndefined')
})
it('always sends a delete rule, defaulting to the non-cascading one', () => {
expect(buildDeleteRecordUrl(BASE, { ...baseParams, recordId: '358' })).toContain(
'&deleteRule=ERROR_IF_DEPENDANTS'
)
expect(
buildDeleteRecordUrl(BASE, {
...baseParams,
recordId: '358',
deleteRule: 'APPLY_UNLINK',
})
).toContain('&deleteRule=APPLY_UNLINK')
})
it('does not ask for the .json variant on operations whose JSON shape is undocumented', () => {
expect(buildSearchRecordsUrl(BASE, { ...baseParams, query: "a='b'" })).not.toContain('.json')
expect(
buildLockRecordUrl(BASE, { ...baseParams, recordId: '18', lockAction: 'check' })
).not.toContain('.json')
})
})
describe('recordUrlLengthError', () => {
it('accepts an ordinary record payload', () => {
const error = recordUrlLengthError(BASE, (base) =>
buildCreateRecordUrl(base, baseParams, { summary: 'A normal contract title' })
)
expect(error).toBeNull()
})
it('explains the URL ceiling rather than letting Agiloft answer 414', () => {
const error = recordUrlLengthError(BASE, (base) =>
buildCreateRecordUrl(base, baseParams, { description: 'x'.repeat(7000) })
)
expect(error).toContain('too large')
expect(error).toContain('carry field values in the URL')
})
})
describe('buildRunActionButtonUrl', () => {
it('uses the /ewws/async prefix EWActionButton is documented under', () => {
const url = buildRunActionButtonUrl(BASE, {
...baseParams,
recordId: '82',
actionButtonField: 'ab_field',
})
expect(url).toContain('/ewws/async/EWActionButton?')
expect(url).toContain('&name=ab_field')
expect(url).toContain('&id=82')
})
})
describe('buildSearchRecordsUrl', () => {
it('sends the saved search label as the documented "search" parameter', () => {
const url = buildSearchRecordsUrl(BASE, {
...baseParams,
search: 'C: Status is Closed',
})
expect(url).toContain('&search=C%3A%20Status%20is%20Closed')
expect(url).not.toContain('query=')
})
it('combines a saved search with an ad hoc query, as EWSearch allows', () => {
const url = buildSearchRecordsUrl(BASE, {
...baseParams,
search: 'C: Status is Closed',
query: "priority='High'",
})
expect(url).toContain('&search=C%3A%20Status%20is%20Closed')
expect(url).toContain("&query=priority%3D'High'")
})
it('omits the query parameter entirely when no query is supplied', () => {
const url = buildSearchRecordsUrl(BASE, { ...baseParams, search: 'All Open' })
expect(url).not.toMatch(/[?&]query=/)
})
})
describe('buildLockRecordUrl', () => {
it('adds force only on unlock, where EWLock accepts it', () => {
const unlock = buildLockRecordUrl(BASE, {
...baseParams,
recordId: '18',
lockAction: 'unlock',
force: true,
})
expect(unlock).toContain('&force=true')
})
it('never sends force on lock or status checks', () => {
for (const lockAction of ['lock', 'check'] as const) {
const url = buildLockRecordUrl(BASE, {
...baseParams,
recordId: '18',
lockAction,
force: true,
})
expect(url).not.toContain('force')
}
})
})
+101 -48
View File
@@ -7,7 +7,7 @@ import type {
AgiloftReadRecordParams,
AgiloftRemoveAttachmentParams,
AgiloftRetrieveAttachmentParams,
AgiloftSavedSearchParams,
AgiloftRunActionButtonParams,
AgiloftSearchRecordsParams,
AgiloftSelectRecordsParams,
} from '@/tools/agiloft/types'
@@ -22,52 +22,88 @@ function encodeTable(params: AgiloftBaseParams) {
}
}
export function buildCreateRecordUrl(base: string, params: AgiloftBaseParams): string {
const { kb, table } = encodeTable(params)
return `${base}/ewws/REST/${kb}/${table}?$lang=en`
}
export function buildReadRecordUrl(base: string, params: AgiloftReadRecordParams): string {
const { kb, table } = encodeTable(params)
const id = encodeURIComponent(params.recordId.trim())
let url = `${base}/ewws/REST/${kb}/${table}/${id}?$lang=en`
if (params.fields) {
const fieldList = params.fields
.split(',')
.map((f) => f.trim())
.filter(Boolean)
for (const field of fieldList) {
url += `&$fields=${encodeURIComponent(field)}`
}
}
return url
}
export function buildUpdateRecordUrl(
base: string,
params: AgiloftBaseParams & { recordId: string }
): string {
const { kb, table } = encodeTable(params)
const id = encodeURIComponent(params.recordId.trim())
return `${base}/ewws/REST/${kb}/${table}/${id}?$lang=en`
}
export function buildDeleteRecordUrl(base: string, params: AgiloftDeleteRecordParams): string {
const { kb, table } = encodeTable(params)
const id = encodeURIComponent(params.recordId.trim())
return `${base}/ewws/REST/${kb}/${table}/${id}?$lang=en`
}
function buildEwBaseQuery(params: AgiloftBaseParams): string {
const { kb, table } = encodeTable(params)
return `$KB=${kb}&$table=${table}&$lang=en`
}
/**
* EWCreate and EWUpdate carry record data in the query string, so an oversized
* payload hits the server's request-line limit rather than a body limit. Tomcat
* allows 8 KB for the whole request line by default; this leaves headroom for
* the method, protocol, and surrounding headers.
*/
export const AGILOFT_MAX_RECORD_URL_LENGTH = 6000
/**
* Serializes a record's field values as the `&field=value` pairs EWCreate and
* EWUpdate expect. Agiloft reads record data straight off the query string;
* there is no documented JSON body form of these operations.
*/
function encodeRecordData(data: Record<string, unknown>): string {
let encoded = ''
for (const [field, value] of Object.entries(data)) {
if (value === undefined || value === null) continue
encoded += `&${encodeURIComponent(field)}=${encodeURIComponent(String(value))}`
}
return encoded
}
/**
* Returns an explanatory message when a record's field data would push the
* request line past what Agiloft accepts, so the caller reports the real cause
* instead of surfacing an opaque 414 from the server.
*/
export function recordUrlLengthError(
instanceUrl: string,
build: (base: string) => string
): string | null {
const url = build(instanceUrl.replace(/\/$/, ''))
if (url.length <= AGILOFT_MAX_RECORD_URL_LENGTH) return null
return `Record data is too large: Agiloft's create and update operations carry field values in the URL, and this request is ${url.length} characters against a ${AGILOFT_MAX_RECORD_URL_LENGTH} limit. Split it into smaller updates or move long text into an attachment.`
}
export function buildCreateRecordUrl(
base: string,
params: AgiloftBaseParams,
data: Record<string, unknown>
): string {
return `${base}/ewws/EWCreate?${buildEwBaseQuery(params)}${encodeRecordData(data)}`
}
export function buildReadRecordUrl(base: string, params: AgiloftReadRecordParams): string {
const id = encodeURIComponent(params.recordId.trim())
return `${base}/ewws/EWRead?${buildEwBaseQuery(params)}&id=${id}`
}
export function buildUpdateRecordUrl(
base: string,
params: AgiloftBaseParams & { recordId: string },
data: Record<string, unknown>
): string {
const id = encodeURIComponent(params.recordId.trim())
return `${base}/ewws/EWUpdate?${buildEwBaseQuery(params)}&id=${id}${encodeRecordData(data)}`
}
/**
* EWDelete requires a delete rule naming the strategy for dependent records;
* omitting it is a malformed request.
*/
export function buildDeleteRecordUrl(base: string, params: AgiloftDeleteRecordParams): string {
const id = encodeURIComponent(params.recordId.trim())
const deleteRule = encodeURIComponent(params.deleteRule ?? 'ERROR_IF_DEPENDANTS')
return `${base}/ewws/EWDelete?${buildEwBaseQuery(params)}&id=${id}&deleteRule=${deleteRule}`
}
export function buildSearchRecordsUrl(base: string, params: AgiloftSearchRecordsParams): string {
const query = encodeURIComponent(params.query)
let url = `${base}/ewws/EWSearch/.json?${buildEwBaseQuery(params)}&query=${query}`
let url = `${base}/ewws/EWSearch?${buildEwBaseQuery(params)}`
if (params.search) {
url += `&search=${encodeURIComponent(params.search.trim())}`
}
if (params.query) {
url += `&query=${encodeURIComponent(params.query)}`
}
if (params.fields) {
const fieldList = params.fields
@@ -91,11 +127,7 @@ export function buildSearchRecordsUrl(base: string, params: AgiloftSearchRecords
export function buildSelectRecordsUrl(base: string, params: AgiloftSelectRecordsParams): string {
const where = encodeURIComponent(params.where)
return `${base}/ewws/EWSelect/.json?${buildEwBaseQuery(params)}&where=${where}`
}
export function buildSavedSearchUrl(base: string, params: AgiloftSavedSearchParams): string {
return `${base}/ewws/EWSavedSearch/.json?${buildEwBaseQuery(params)}`
return `${base}/ewws/EWSelect?${buildEwBaseQuery(params)}&where=${where}`
}
export function buildRetrieveAttachmentUrl(
@@ -124,9 +156,17 @@ export function buildAttachmentInfoUrl(base: string, params: AgiloftAttachmentIn
return `${base}/ewws/EWAttachInfo/.json?${buildEwBaseQuery(params)}&id=${id}&field=${fieldName}`
}
/**
* `force` is only meaningful on the DELETE (unlock) variant, where it lets an
* admin release a lock held by another user.
*/
export function buildLockRecordUrl(base: string, params: AgiloftLockRecordParams): string {
const id = encodeURIComponent(params.recordId.trim())
return `${base}/ewws/EWLock/.json?${buildEwBaseQuery(params)}&id=${id}`
let url = `${base}/ewws/EWLock?${buildEwBaseQuery(params)}&id=${id}`
if (params.lockAction === 'unlock' && params.force) {
url += '&force=true'
}
return url
}
export function buildAttachFileUrl(
@@ -147,7 +187,7 @@ export function buildGetChoiceLineIdUrl(
): string {
const field = encodeURIComponent(params.fieldName.trim())
const value = encodeURIComponent(params.value.trim())
return `${base}/ewws/EWGetChoiceLineId/.json?${buildEwBaseQuery(params)}&field=${field}&value=${value}`
return `${base}/ewws/EWGetChoiceLineId?${buildEwBaseQuery(params)}&field=${field}&value=${value}`
}
export function getLockHttpMethod(lockAction: string): HttpMethod {
@@ -160,3 +200,16 @@ export function getLockHttpMethod(lockAction: string): HttpMethod {
return 'GET'
}
}
/**
* EWActionButton runs asynchronously and therefore lives under the `/ewws/async`
* prefix rather than `/ewws` directly.
*/
export function buildRunActionButtonUrl(
base: string,
params: AgiloftRunActionButtonParams
): string {
const id = encodeURIComponent(params.recordId.trim())
const name = encodeURIComponent(params.actionButtonField.trim())
return `${base}/ewws/async/EWActionButton?${buildEwBaseQuery(params)}&name=${name}&id=${id}`
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -61,6 +61,7 @@ import {
agiloftReadRecordTool,
agiloftRemoveAttachmentTool,
agiloftRetrieveAttachmentTool,
agiloftRunActionButtonTool,
agiloftSavedSearchTool,
agiloftSearchRecordsTool,
agiloftSelectRecordsTool,
@@ -4992,6 +4993,7 @@ export const tools: Record<string, ToolConfig> = {
agiloft_read_record: agiloftReadRecordTool,
agiloft_remove_attachment: agiloftRemoveAttachmentTool,
agiloft_retrieve_attachment: agiloftRetrieveAttachmentTool,
agiloft_run_action_button: agiloftRunActionButtonTool,
agiloft_saved_search: agiloftSavedSearchTool,
agiloft_search_records: agiloftSearchRecordsTool,
agiloft_select_records: agiloftSelectRecordsTool,