Skip to content

Commit 81e04a8

Browse files
authored
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.
1 parent bd91ab7 commit 81e04a8

32 files changed

Lines changed: 1455 additions & 382 deletions

File tree

apps/docs/content/docs/en/integrations/agiloft.mdx

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ In Sim, the Agiloft integration enables your agents to manage contracts and reco
3434

3535
## Usage Instructions
3636

37-
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.
37+
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.
3838

3939

4040

@@ -129,6 +129,7 @@ Delete a record from an Agiloft table.
129129
| `password` | string | Yes | Agiloft password |
130130
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
131131
| `recordId` | string | Yes | ID of the record to delete |
132+
| `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 |
132133

133134
#### Output
134135

@@ -174,13 +175,14 @@ Lock, unlock, or check the lock status of an Agiloft record.
174175
| `table` | string | Yes | Table name \(e.g., "contracts"\) |
175176
| `recordId` | string | Yes | ID of the record to lock, unlock, or check |
176177
| `lockAction` | string | Yes | Action to perform: "lock", "unlock", or "check" |
178+
| `force` | boolean | No | Unlock only: release a lock held by another user. Requires membership in the admin group. |
177179

178180
#### Output
179181

180182
| Parameter | Type | Description |
181183
| --------- | ---- | ----------- |
182184
| `id` | string | Record ID |
183-
| `lockStatus` | string | Lock status \(e.g., "LOCKED", "UNLOCKED"\) |
185+
| `lockStatus` | string | Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free |
184186
| `lockedBy` | string | Username of the user who locked the record |
185187
| `lockExpiresInMinutes` | number | Minutes until the lock expires |
186188

@@ -255,9 +257,9 @@ Download an attached file from an Agiloft record field.
255257
| --------- | ---- | ----------- |
256258
| `file` | file | Downloaded attachment file |
257259

258-
### Agiloft Saved Search
260+
### Agiloft Run Action Button
259261

260-
List saved searches defined for an Agiloft table.
262+
Run an action button on an Agiloft record, such as an approval or send-for-signature step.
261263

262264
#### Input
263265

@@ -267,17 +269,40 @@ List saved searches defined for an Agiloft table.
267269
| `knowledgeBase` | string | Yes | Knowledge base name |
268270
| `login` | string | Yes | Agiloft username |
269271
| `password` | string | Yes | Agiloft password |
270-
| `table` | string | Yes | Table name to list saved searches for \(e.g., "contracts"\) |
272+
| `table` | string | Yes | Table name \(e.g., "contracts", "case"\) |
273+
| `recordId` | string | Yes | ID of the record to run the action button on |
274+
| `actionButtonField` | string | Yes | Logical name of the field holding the action button \(e.g., "ab_field"\) |
271275

272276
#### Output
273277

274278
| Parameter | Type | Description |
275279
| --------- | ---- | ----------- |
276-
| `searches` | array | List of saved searches for the table |
277-
|`name` | string | Saved search name |
278-
|`label` | string | Saved search display label |
279-
|`id` | number | Saved search database identifier |
280-
|`description` | string | Saved search description |
280+
| `recordId` | string | ID of the record the action button was run on |
281+
| `callbackId` | string | Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID |
282+
283+
### saved_search
284+
285+
286+
### Agiloft Saved Search (retired)
287+
288+
Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.
289+
290+
#### Input
291+
292+
| Parameter | Type | Required | Description |
293+
| --------- | ---- | -------- | ----------- |
294+
| `instanceUrl` | string | No | Agiloft instance URL |
295+
| `knowledgeBase` | string | No | Knowledge base name |
296+
| `login` | string | No | Agiloft username |
297+
| `password` | string | No | Agiloft password |
298+
| `table` | string | No | Table name |
299+
| `output` | string | No | No description |
300+
301+
#### Output
302+
303+
| Parameter | Type | Description |
304+
| --------- | ---- | ----------- |
305+
| `searches` | array | Always empty; this operation is retired |
281306

282307
### Agiloft Search Records
283308

@@ -292,19 +317,20 @@ Search for records in an Agiloft table using a query.
292317
| `login` | string | Yes | Agiloft username |
293318
| `password` | string | Yes | Agiloft password |
294319
| `table` | string | Yes | Table name to search in \(e.g., "contracts", "contacts.employees"\) |
295-
| `query` | string | Yes | Search query using Agiloft query syntax \(e.g., "status=\'Active\'" or "company_name~=\'Acme\'"\) |
320+
| `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. |
321+
| `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. |
296322
| `fields` | string | No | Comma-separated list of field names to include in the results |
297323
| `page` | string | No | Page number for paginated results \(starting from 0\) |
298-
| `limit` | string | No | Maximum number of records to return per page |
324+
| `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. |
299325

300326
#### Output
301327

302328
| Parameter | Type | Description |
303329
| --------- | ---- | ----------- |
304330
| `records` | json | Array of matching records with their field values |
305-
| `totalCount` | number | Total number of matching records |
306-
| `page` | number | Current page number |
307-
| `limit` | number | Records per page |
331+
| `totalCount` | number | Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set. |
332+
| `page` | number | Page number that was requested \(0-based\) |
333+
| `limit` | number | Page size that was requested; 0 when no limit was sent and Agiloft chose one |
308334

309335
### Agiloft Select Records
310336

@@ -319,7 +345,7 @@ Select record IDs matching a SQL WHERE clause from an Agiloft table.
319345
| `login` | string | Yes | Agiloft username |
320346
| `password` | string | Yes | Agiloft password |
321347
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
322-
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\) |
348+
| `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. |
323349

324350
#### Output
325351

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
hybridAuthMockFns,
7+
inputValidationMock,
8+
inputValidationMockFns,
9+
} from '@sim/testing'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
12+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
13+
14+
import { POST } from '@/app/api/tools/agiloft/create_record/route'
15+
import { POST as SEARCH } from '@/app/api/tools/agiloft/search_records/route'
16+
import { POST as SELECT } from '@/app/api/tools/agiloft/select_records/route'
17+
18+
const PINNED_IP = '93.184.216.34'
19+
20+
const baseBody = {
21+
instanceUrl: 'https://example.agiloft.com',
22+
knowledgeBase: 'Demo',
23+
login: 'admin',
24+
password: 'secret',
25+
table: 'contacts.employees',
26+
data: JSON.stringify({ first_name: 'John', last_name: 'Doe' }),
27+
}
28+
29+
function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: string }) {
30+
return {
31+
ok: body.ok ?? true,
32+
status: body.ok === false ? 400 : 200,
33+
statusText: '',
34+
headers: new Headers(),
35+
body: null,
36+
text: async () => body.text ?? '',
37+
json: async () => body.json ?? {},
38+
arrayBuffer: async () => new ArrayBuffer(0),
39+
}
40+
}
41+
42+
beforeEach(() => {
43+
vi.clearAllMocks()
44+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
45+
success: true,
46+
userId: 'user-1',
47+
authType: 'internal_jwt',
48+
})
49+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
50+
isValid: true,
51+
resolvedIP: PINNED_IP,
52+
originalHostname: 'example.agiloft.com',
53+
})
54+
})
55+
56+
describe('POST /api/tools/agiloft/create_record', () => {
57+
it("reads the record ID out of EWCreate's EWREST_id assignment", async () => {
58+
inputValidationMockFns.mockSecureFetchWithPinnedIP
59+
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
60+
.mockResolvedValueOnce(mockSecureFetchResponse({ text: "EWREST_id='353';" }))
61+
.mockResolvedValueOnce(mockSecureFetchResponse({}))
62+
63+
const response = await POST(createMockRequest('POST', baseBody))
64+
const data = (await response.json()) as {
65+
success: boolean
66+
output: { id: string | null }
67+
}
68+
69+
expect(data.success).toBe(true)
70+
expect(data.output.id).toBe('353')
71+
72+
const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
73+
expect(operationCall[0]).toContain('/ewws/EWCreate?')
74+
expect(operationCall[0]).toContain('&first_name=John')
75+
expect(operationCall[2]).toMatchObject({ method: 'POST' })
76+
})
77+
78+
it('fails loudly when Agiloft answers 200 with something that is not an EWREST body', async () => {
79+
inputValidationMockFns.mockSecureFetchWithPinnedIP
80+
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
81+
.mockResolvedValueOnce(
82+
mockSecureFetchResponse({ text: 'Error executing query, please consult logs' })
83+
)
84+
.mockResolvedValueOnce(mockSecureFetchResponse({}))
85+
86+
const response = await POST(createMockRequest('POST', baseBody))
87+
const data = (await response.json()) as { success: boolean; error?: string }
88+
89+
expect(data.success).toBe(false)
90+
expect(data.error).toContain('did not return a record ID')
91+
})
92+
93+
it('rejects a data payload that is not a JSON object', async () => {
94+
const response = await POST(
95+
createMockRequest('POST', { ...baseBody, data: '["not", "an", "object"]' })
96+
)
97+
const data = (await response.json()) as { success: boolean; error?: string }
98+
99+
expect(data.success).toBe(false)
100+
expect(data.error).toContain('must be a JSON object')
101+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
102+
})
103+
})
104+
105+
describe('empty EWREST bodies on search and select', () => {
106+
const listBase = {
107+
instanceUrl: 'https://example.agiloft.com',
108+
knowledgeBase: 'Demo',
109+
login: 'admin',
110+
password: 'secret',
111+
table: 'helpdesk_case',
112+
}
113+
114+
function arrange(text: string) {
115+
inputValidationMockFns.mockSecureFetchWithPinnedIP
116+
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok' } }))
117+
.mockResolvedValueOnce(mockSecureFetchResponse({ text }))
118+
.mockResolvedValueOnce(mockSecureFetchResponse({}))
119+
}
120+
121+
it('treats a plain-text refusal from EWSearch as a failure, not an empty result', async () => {
122+
arrange('Error executing query, please consult logs')
123+
124+
const response = await SEARCH(
125+
createMockRequest('POST', { ...listBase, query: "priority='High'" })
126+
)
127+
const data = (await response.json()) as { success: boolean; error?: string }
128+
129+
expect(data.success).toBe(false)
130+
expect(data.error).toContain('did not return search results')
131+
})
132+
133+
it('still reports a genuinely empty EWSearch result as a success', async () => {
134+
arrange("EWREST_id_length = '0';")
135+
136+
const response = await SEARCH(
137+
createMockRequest('POST', { ...listBase, query: "priority='High'" })
138+
)
139+
const data = (await response.json()) as {
140+
success: boolean
141+
output: { records: unknown[]; totalCount: number }
142+
}
143+
144+
expect(data.success).toBe(true)
145+
expect(data.output.records).toEqual([])
146+
expect(data.output.totalCount).toBe(0)
147+
})
148+
149+
it('treats a plain-text refusal from EWSelect as a failure, not an empty result', async () => {
150+
arrange('Error executing query, please consult logs')
151+
152+
const response = await SELECT(
153+
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
154+
)
155+
const data = (await response.json()) as { success: boolean; error?: string }
156+
157+
expect(data.success).toBe(false)
158+
expect(data.error).toContain('did not return a result set')
159+
})
160+
161+
it('still reports a genuinely empty EWSelect result as a success', async () => {
162+
arrange("EWREST_id_length = '0';")
163+
164+
const response = await SELECT(
165+
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
166+
)
167+
const data = (await response.json()) as {
168+
success: boolean
169+
output: { recordIds: string[]; totalCount: number }
170+
}
171+
172+
expect(data.success).toBe(true)
173+
expect(data.output.recordIds).toEqual([])
174+
expect(data.output.totalCount).toBe(0)
175+
})
176+
})

apps/sim/app/api/tools/agiloft/create_record/route.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
910
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
10-
import { buildCreateRecordUrl } from '@/tools/agiloft/utils'
11+
import { buildCreateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
1112
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
1213

1314
export const dynamic = 'force-dynamic'
@@ -49,46 +50,62 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4950
if (!parsed.success) return parsed.response
5051
const params = parsed.data.body
5152

52-
let body: string
53+
let fieldValues: Record<string, unknown>
5354
try {
54-
body = JSON.stringify(JSON.parse(params.data))
55+
const parsedData = JSON.parse(params.data)
56+
if (typeof parsedData !== 'object' || parsedData === null || Array.isArray(parsedData)) {
57+
throw new Error('not an object')
58+
}
59+
fieldValues = parsedData as Record<string, unknown>
5560
} catch {
5661
return NextResponse.json({
5762
success: false,
5863
output: { id: null, fields: {} },
59-
error: 'Invalid JSON in data parameter',
64+
error: 'The data parameter must be a JSON object of field names to values',
65+
})
66+
}
67+
68+
const oversized = recordUrlLengthError(params.instanceUrl, (base) =>
69+
buildCreateRecordUrl(base, params, fieldValues)
70+
)
71+
if (oversized) {
72+
return NextResponse.json({
73+
success: false,
74+
output: { id: null, fields: {} },
75+
error: oversized,
6076
})
6177
}
6278

6379
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
6480
params,
6581
(base) => ({
66-
url: buildCreateRecordUrl(base, params),
82+
url: buildCreateRecordUrl(base, params, fieldValues),
6783
method: 'POST',
68-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
69-
body,
84+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
7085
}),
7186
async (response) => {
87+
const body = await response.text()
88+
7289
if (!response.ok) {
73-
const errorText = await response.text()
7490
return {
7591
success: false,
7692
output: { id: null, fields: {} },
77-
error: `Agiloft error: ${response.status} - ${errorText}`,
93+
error: `Agiloft error: ${response.status} - ${body}`,
7894
}
7995
}
8096

81-
const data = (await response.json()) as Record<string, unknown>
82-
const result = (data.result ?? data) as Record<string, unknown>
83-
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
97+
/** EWCreate answers with a single assignment: EWREST_id='353'; */
98+
const { id, fields } = toRecord(parseEwRest(body))
8499

85-
return {
86-
success: data.success !== false,
87-
output: {
88-
id: id != null ? String(id) : null,
89-
fields: result ?? {},
90-
},
100+
if (id === null) {
101+
return {
102+
success: false,
103+
output: { id: null, fields },
104+
error: `Agiloft did not return a record ID: ${body.trim() || '(empty response)'}`,
105+
}
91106
}
107+
108+
return { success: true, output: { id, fields } }
92109
}
93110
)
94111

0 commit comments

Comments
 (0)