Skip to content

Commit 697568a

Browse files
committed
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 c9a29e1 commit 697568a

17 files changed

Lines changed: 319 additions & 12 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,30 @@ Run an action button on an Agiloft record, such as an approval or send-for-signa
280280
| `recordId` | string | ID of the record the action button was run on |
281281
| `callbackId` | string | Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID |
282282

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 |
306+
283307
### Agiloft Search Records
284308

285309
Search for records in an Agiloft table using a query.

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1212
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
1313

1414
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'
1517

1618
const PINNED_IP = '93.184.216.34'
1719

@@ -99,3 +101,76 @@ describe('POST /api/tools/agiloft/create_record', () => {
99101
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
100102
})
101103
})
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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
1010
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
11-
import { buildCreateRecordUrl } from '@/tools/agiloft/utils'
11+
import { buildCreateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
1212
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
1313

1414
export const dynamic = 'force-dynamic'
@@ -65,6 +65,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6565
})
6666
}
6767

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,
76+
})
77+
}
78+
6879
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
6980
params,
7081
(base) => ({

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7171

7272
/**
7373
* EWSearch answers with EWREST_length plus one EWREST_<field>_<index>
74-
* assignment per field per row. An empty result set is reported as
75-
* EWREST_id_length = '0'.
74+
* assignment per field per row, and reports an empty result set as
75+
* EWREST_id_length = '0'. A body with no assignments at all is therefore
76+
* never a legitimate empty search — it is a refusal Agiloft returned
77+
* with HTTP 200, such as an invalid query or an unknown saved search.
7678
*/
77-
const { records, count } = toSearchRecords(parseEwRest(body))
79+
const values = parseEwRest(body)
80+
if (values.size === 0) {
81+
return {
82+
success: false,
83+
output: { records: [], totalCount: 0, page, limit },
84+
error: `Agiloft did not return search results: ${body.trim() || '(empty response)'}`,
85+
}
86+
}
87+
88+
const { records, count } = toSearchRecords(values)
7889

7990
return {
8091
success: true,

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6969

7070
/**
7171
* EWSelect answers with EWREST_id_length followed by one EWREST_id_<n>
72-
* assignment per match. Zero matches yields the length line alone.
72+
* assignment per match, and zero matches still yields the length line.
73+
* A body with no assignments at all is therefore never a legitimate
74+
* empty result — it is a refusal Agiloft returned with HTTP 200, most
75+
* often invalid WHERE-clause SQL.
7376
*/
74-
const { recordIds, count } = toRecordIds(parseEwRest(body))
77+
const values = parseEwRest(body)
78+
if (values.size === 0) {
79+
return {
80+
success: false,
81+
output: { recordIds: [], totalCount: 0 },
82+
error: `Agiloft did not return a result set: ${body.trim() || '(empty response)'}`,
83+
}
84+
}
85+
86+
const { recordIds, count } = toRecordIds(values)
7587

7688
return { success: true, output: { recordIds, totalCount: count } }
7789
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
1010
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
11-
import { buildUpdateRecordUrl } from '@/tools/agiloft/utils'
11+
import { buildUpdateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
1212
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
1313

1414
export const dynamic = 'force-dynamic'
@@ -65,6 +65,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6565
})
6666
}
6767

68+
const oversized = recordUrlLengthError(params.instanceUrl, (base) =>
69+
buildUpdateRecordUrl(base, params, fieldValues)
70+
)
71+
if (oversized) {
72+
return NextResponse.json({
73+
success: false,
74+
output: { id: null, fields: {} },
75+
error: oversized,
76+
})
77+
}
78+
6879
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
6980
params,
7081
(base) => ({

apps/sim/blocks/blocks/agiloft.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,8 @@ export const AgiloftBlock: BlockConfig = {
409409
'agiloft_remove_attachment',
410410
'agiloft_retrieve_attachment',
411411
'agiloft_run_action_button',
412+
// Retired, but retained so blocks saved with operation='saved_search' still resolve.
413+
'agiloft_saved_search',
412414
'agiloft_search_records',
413415
'agiloft_select_records',
414416
'agiloft_update_record',
@@ -525,7 +527,7 @@ export const AgiloftBlock: BlockConfig = {
525527
},
526528
recordId: {
527529
type: 'string',
528-
description: 'ID of the record the file operation was performed on',
530+
description: 'ID of the record the operation was performed on',
529531
condition: {
530532
field: 'operation',
531533
value: ['attach_file', 'remove_attachment', 'run_action_button'],

apps/sim/tools/agiloft/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { agiloftReadRecordTool } from '@/tools/agiloft/read_record'
88
export { agiloftRemoveAttachmentTool } from '@/tools/agiloft/remove_attachment'
99
export { agiloftRetrieveAttachmentTool } from '@/tools/agiloft/retrieve_attachment'
1010
export { agiloftRunActionButtonTool } from '@/tools/agiloft/run_action_button'
11+
export { agiloftSavedSearchTool } from '@/tools/agiloft/saved_search'
1112
export { agiloftSearchRecordsTool } from '@/tools/agiloft/search_records'
1213
export { agiloftSelectRecordsTool } from '@/tools/agiloft/select_records'
1314
export * from '@/tools/agiloft/types'
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { agiloftSavedSearchTool } from '@/tools/agiloft/saved_search'
6+
import toolIds from '@/tools/generated/tool-ids'
7+
8+
describe('retired agiloft_saved_search', () => {
9+
it('keeps its id registered so workflows saved with that operation still resolve a tool', () => {
10+
expect(toolIds).toContain('agiloft_saved_search')
11+
expect(agiloftSavedSearchTool.id).toBe('agiloft_saved_search')
12+
})
13+
14+
it('fails with a migration hint instead of calling an undocumented endpoint', async () => {
15+
const result = await agiloftSavedSearchTool.directExecution?.({})
16+
17+
expect(result?.success).toBe(false)
18+
expect(result?.error).toContain('Search Records')
19+
})
20+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { AgiloftSavedSearchParams, AgiloftSavedSearchResponse } from '@/tools/agiloft/types'
2+
import type { ToolConfig } from '@/tools/types'
3+
4+
/**
5+
* Retired operation, kept registered so workflows saved while it was offered
6+
* still resolve a tool instead of failing with "Tool not found".
7+
*
8+
* `EWSavedSearch` appears in Agiloft's Scope Parameter operation list, so the
9+
* endpoint exists, but it has no documentation page — neither its URL
10+
* parameters nor its response shape can be verified. The previous
11+
* implementation guessed both and could only ever report an empty list, which
12+
* reads as "this table has no saved searches". Running a saved search is now
13+
* supported for real through the Search Records operation's Saved Search
14+
* field, so this fails fast and points there rather than issuing a request
15+
* whose behavior nobody can predict.
16+
*/
17+
export const agiloftSavedSearchTool: ToolConfig<
18+
AgiloftSavedSearchParams,
19+
AgiloftSavedSearchResponse
20+
> = {
21+
id: 'agiloft_saved_search',
22+
name: 'Agiloft Saved Search (retired)',
23+
description:
24+
'Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.',
25+
version: '1.0.0',
26+
27+
params: {
28+
instanceUrl: {
29+
type: 'string',
30+
required: false,
31+
visibility: 'user-only',
32+
description: 'Agiloft instance URL',
33+
},
34+
knowledgeBase: {
35+
type: 'string',
36+
required: false,
37+
visibility: 'user-only',
38+
description: 'Knowledge base name',
39+
},
40+
login: {
41+
type: 'string',
42+
required: false,
43+
visibility: 'user-only',
44+
description: 'Agiloft username',
45+
},
46+
password: {
47+
type: 'string',
48+
required: false,
49+
visibility: 'user-only',
50+
description: 'Agiloft password',
51+
},
52+
table: {
53+
type: 'string',
54+
required: false,
55+
visibility: 'user-or-llm',
56+
description: 'Table name',
57+
},
58+
},
59+
60+
/** Fails without a network call — there is no endpoint we can correctly call. */
61+
directExecution: async () => ({
62+
success: false,
63+
output: { searches: [] },
64+
error:
65+
'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.',
66+
}),
67+
68+
request: {
69+
url: () => '/api/tools/agiloft/search_records',
70+
method: 'POST',
71+
headers: () => ({ 'Content-Type': 'application/json' }),
72+
body: () => ({}),
73+
},
74+
75+
transformResponse: async () => ({
76+
success: false,
77+
output: { searches: [] },
78+
error: 'The Agiloft "Saved Search" operation has been retired.',
79+
}),
80+
81+
outputs: {
82+
searches: {
83+
type: 'array',
84+
description: 'Always empty; this operation is retired',
85+
items: { type: 'object' },
86+
},
87+
},
88+
}

0 commit comments

Comments
 (0)