mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 11:58:12 -05:00
fix(api): tool input parsing into table from agent output (#2879)
* fix(api): transformTable to map agent output to table subblock format * fix api * add test
This commit is contained in:
committed by
GitHub
parent
3a923648cb
commit
1dbf92db3f
@@ -1,5 +1,6 @@
|
||||
import type { RequestParams, RequestResponse } from '@/tools/http/types'
|
||||
import { getDefaultHeaders, processUrl, transformTable } from '@/tools/http/utils'
|
||||
import { getDefaultHeaders, processUrl } from '@/tools/http/utils'
|
||||
import { transformTable } from '@/tools/shared/table'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { transformTable } from '@/tools/shared/table'
|
||||
import type { TableRow } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('HTTPRequestUtils')
|
||||
@@ -119,28 +120,3 @@ export const shouldUseProxy = (url: string): boolean => {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a table from the store format to a key-value object
|
||||
* Local copy of the function to break circular dependencies
|
||||
* @param table Array of table rows from the store
|
||||
* @returns Record of key-value pairs
|
||||
*/
|
||||
export const transformTable = (table: TableRow[] | null): Record<string, any> => {
|
||||
if (!table) return {}
|
||||
|
||||
return table.reduce(
|
||||
(acc, row) => {
|
||||
if (row.cells?.Key && row.cells?.Value !== undefined) {
|
||||
// Extract the Value cell as is - it should already be properly resolved
|
||||
// by the InputResolver based on variable type (number, string, boolean etc.)
|
||||
const value = row.cells.Value
|
||||
|
||||
// Store the correctly typed value in the result object
|
||||
acc[row.cells.Key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KnowledgeCreateDocumentResponse } from '@/tools/knowledge/types'
|
||||
import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/params'
|
||||
import { enrichKBTagsSchema } from '@/tools/schema-enrichers'
|
||||
import { formatDocumentTagsForAPI, parseDocumentTags } from '@/tools/shared/tags'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumentResponse> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KnowledgeSearchResponse } from '@/tools/knowledge/types'
|
||||
import { parseTagFilters } from '@/tools/params'
|
||||
import { enrichKBTagFiltersSchema } from '@/tools/schema-enrichers'
|
||||
import { parseTagFilters } from '@/tools/shared/tags'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
evaluateSubBlockCondition,
|
||||
type SubBlockCondition,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { isEmptyTagValue } from '@/tools/shared/tags'
|
||||
import type { ParameterVisibility, ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
@@ -23,194 +23,6 @@ export function isNonEmpty(value: unknown): boolean {
|
||||
// Tag/Value Parsing Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Document tag entry format used in create_document tool
|
||||
*/
|
||||
export interface DocumentTagEntry {
|
||||
tagName: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag filter entry format used in search tool
|
||||
*/
|
||||
export interface TagFilterEntry {
|
||||
tagName: string
|
||||
tagSlot?: string
|
||||
tagValue: string | number | boolean
|
||||
fieldType?: string
|
||||
operator?: string
|
||||
valueTo?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag value is effectively empty (unfilled/default entry)
|
||||
*/
|
||||
function isEmptyTagEntry(entry: Record<string, unknown>): boolean {
|
||||
if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag-based value is effectively empty (only contains default/unfilled entries).
|
||||
* Works for both documentTags and tagFilters parameters in various formats.
|
||||
*
|
||||
* @param value - The tag value to check (can be JSON string, array, or object)
|
||||
* @returns true if the value is empty or only contains unfilled entries
|
||||
*/
|
||||
export function isEmptyTagValue(value: unknown): boolean {
|
||||
if (!value) return true
|
||||
|
||||
// Handle JSON string format
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) return false
|
||||
if (parsed.length === 0) return true
|
||||
return parsed.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle array format directly
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return true
|
||||
return value.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
}
|
||||
|
||||
// Handle object format (LLM format: { "Category": "foo", "Priority": 5 })
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return true
|
||||
return entries.every(([, val]) => val === undefined || val === null || val === '')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters valid document tags from an array, removing empty entries
|
||||
*/
|
||||
function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] {
|
||||
return tags
|
||||
.filter((entry): entry is Record<string, unknown> => {
|
||||
if (typeof entry !== 'object' || entry === null) return false
|
||||
const e = entry as Record<string, unknown>
|
||||
if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false
|
||||
if (e.value === undefined || e.value === null || e.value === '') return false
|
||||
return true
|
||||
})
|
||||
.map((entry) => ({
|
||||
tagName: String(entry.tagName),
|
||||
value: String(entry.value),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses document tags from various formats into a normalized array format.
|
||||
* Used by create_document tool to handle tags from both UI and LLM sources.
|
||||
*
|
||||
* @param value - Document tags in object, array, or JSON string format
|
||||
* @returns Normalized array of document tag entries, or empty array if invalid
|
||||
*/
|
||||
export function parseDocumentTags(value: unknown): DocumentTagEntry[] {
|
||||
if (!value) return []
|
||||
|
||||
// Handle object format from LLM: { "Category": "foo", "Priority": 5 }
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
|
||||
return Object.entries(value)
|
||||
.filter(([tagName, tagValue]) => {
|
||||
if (!tagName || tagName.trim() === '') return false
|
||||
if (tagValue === undefined || tagValue === null || tagValue === '') return false
|
||||
return true
|
||||
})
|
||||
.map(([tagName, tagValue]) => ({
|
||||
tagName,
|
||||
value: String(tagValue),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle JSON string format from UI
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
return filterValidDocumentTags(parsed)
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON, return empty
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle array format directly
|
||||
if (Array.isArray(value)) {
|
||||
return filterValidDocumentTags(value)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses tag filters from various formats into a normalized StructuredFilter array.
|
||||
* Used by search tool to handle tag filters from both UI and LLM sources.
|
||||
*
|
||||
* @param value - Tag filters in array or JSON string format
|
||||
* @returns Normalized array of structured filters, or empty array if invalid
|
||||
*/
|
||||
export function parseTagFilters(value: unknown): StructuredFilter[] {
|
||||
if (!value) return []
|
||||
|
||||
let tagFilters = value
|
||||
|
||||
// Handle JSON string format
|
||||
if (typeof tagFilters === 'string') {
|
||||
try {
|
||||
tagFilters = JSON.parse(tagFilters)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Must be an array at this point
|
||||
if (!Array.isArray(tagFilters)) return []
|
||||
|
||||
return tagFilters
|
||||
.filter((filter): filter is Record<string, unknown> => {
|
||||
if (typeof filter !== 'object' || filter === null) return false
|
||||
const f = filter as Record<string, unknown>
|
||||
if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false
|
||||
if (f.fieldType === 'boolean') {
|
||||
return f.tagValue !== undefined
|
||||
}
|
||||
if (f.tagValue === undefined || f.tagValue === null) return false
|
||||
if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false
|
||||
return true
|
||||
})
|
||||
.map((filter) => ({
|
||||
tagName: filter.tagName as string,
|
||||
tagSlot: (filter.tagSlot as string) || '',
|
||||
fieldType: (filter.fieldType as string) || 'text',
|
||||
operator: (filter.operator as string) || 'eq',
|
||||
value: filter.tagValue as string | number | boolean,
|
||||
valueTo: filter.valueTo as string | number | undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts parsed document tags to the format expected by the create document API.
|
||||
* Returns the documentTagsData JSON string if there are valid tags.
|
||||
*/
|
||||
export function formatDocumentTagsForAPI(tags: DocumentTagEntry[]): { documentTagsData?: string } {
|
||||
if (tags.length === 0) return {}
|
||||
return {
|
||||
documentTagsData: JSON.stringify(tags),
|
||||
}
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
label: string
|
||||
value: string
|
||||
|
||||
38
apps/sim/tools/shared/table.ts
Normal file
38
apps/sim/tools/shared/table.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { TableRow } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Transforms a table from the store format to a key-value object.
|
||||
*/
|
||||
export const transformTable = (
|
||||
table: TableRow[] | Record<string, any> | string | null
|
||||
): Record<string, any> => {
|
||||
if (!table) return {}
|
||||
|
||||
if (typeof table === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(table) as TableRow[] | Record<string, any>
|
||||
return transformTable(parsed)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(table)) {
|
||||
return table.reduce(
|
||||
(acc, row) => {
|
||||
if (row.cells?.Key && row.cells?.Value !== undefined) {
|
||||
const value = row.cells.Value
|
||||
acc[row.cells.Key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof table === 'object') {
|
||||
return table
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
168
apps/sim/tools/shared/tags.ts
Normal file
168
apps/sim/tools/shared/tags.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
|
||||
/**
|
||||
* Document tag entry format used in create_document tool.
|
||||
*/
|
||||
export interface DocumentTagEntry {
|
||||
tagName: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag filter entry format used in search tool.
|
||||
*/
|
||||
export interface TagFilterEntry {
|
||||
tagName: string
|
||||
tagSlot?: string
|
||||
tagValue: string | number | boolean
|
||||
fieldType?: string
|
||||
operator?: string
|
||||
valueTo?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag value is effectively empty (unfilled/default entry).
|
||||
*/
|
||||
function isEmptyTagEntry(entry: Record<string, unknown>): boolean {
|
||||
if (!entry.tagName || (typeof entry.tagName === 'string' && entry.tagName.trim() === '')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag-based value is effectively empty (only contains default/unfilled entries).
|
||||
*/
|
||||
export function isEmptyTagValue(value: unknown): boolean {
|
||||
if (!value) return true
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) return false
|
||||
if (parsed.length === 0) return true
|
||||
return parsed.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return true
|
||||
return value.every((entry: Record<string, unknown>) => isEmptyTagEntry(entry))
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return true
|
||||
return entries.every(([, val]) => val === undefined || val === null || val === '')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters valid document tags from an array, removing empty entries.
|
||||
*/
|
||||
function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] {
|
||||
return tags
|
||||
.filter((entry): entry is Record<string, unknown> => {
|
||||
if (typeof entry !== 'object' || entry === null) return false
|
||||
const e = entry as Record<string, unknown>
|
||||
if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false
|
||||
if (e.value === undefined || e.value === null || e.value === '') return false
|
||||
return true
|
||||
})
|
||||
.map((entry) => ({
|
||||
tagName: String(entry.tagName),
|
||||
value: String(entry.value),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses document tags from various formats into a normalized array format.
|
||||
*/
|
||||
export function parseDocumentTags(value: unknown): DocumentTagEntry[] {
|
||||
if (!value) return []
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
|
||||
return Object.entries(value)
|
||||
.filter(([tagName, tagValue]) => {
|
||||
if (!tagName || tagName.trim() === '') return false
|
||||
if (tagValue === undefined || tagValue === null || tagValue === '') return false
|
||||
return true
|
||||
})
|
||||
.map(([tagName, tagValue]) => ({
|
||||
tagName,
|
||||
value: String(tagValue),
|
||||
}))
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
return filterValidDocumentTags(parsed)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return filterValidDocumentTags(value)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses tag filters from various formats into a normalized StructuredFilter array.
|
||||
*/
|
||||
export function parseTagFilters(value: unknown): StructuredFilter[] {
|
||||
if (!value) return []
|
||||
|
||||
let tagFilters = value
|
||||
|
||||
if (typeof tagFilters === 'string') {
|
||||
try {
|
||||
tagFilters = JSON.parse(tagFilters)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(tagFilters)) return []
|
||||
|
||||
return tagFilters
|
||||
.filter((filter): filter is Record<string, unknown> => {
|
||||
if (typeof filter !== 'object' || filter === null) return false
|
||||
const f = filter as Record<string, unknown>
|
||||
if (!f.tagName || (typeof f.tagName === 'string' && f.tagName.trim() === '')) return false
|
||||
if (f.fieldType === 'boolean') {
|
||||
return f.tagValue !== undefined
|
||||
}
|
||||
if (f.tagValue === undefined || f.tagValue === null) return false
|
||||
if (typeof f.tagValue === 'string' && f.tagValue.trim().length === 0) return false
|
||||
return true
|
||||
})
|
||||
.map((filter) => ({
|
||||
tagName: filter.tagName as string,
|
||||
tagSlot: (filter.tagSlot as string) || '',
|
||||
fieldType: (filter.fieldType as string) || 'text',
|
||||
operator: (filter.operator as string) || 'eq',
|
||||
value: filter.tagValue as string | number | boolean,
|
||||
valueTo: filter.valueTo as string | number | undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts parsed document tags to the format expected by the create document API.
|
||||
*/
|
||||
export function formatDocumentTagsForAPI(tags: DocumentTagEntry[]): { documentTagsData?: string } {
|
||||
if (tags.length === 0) return {}
|
||||
return {
|
||||
documentTagsData: JSON.stringify(tags),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMockFetch, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { transformTable } from '@/tools/shared/table'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import {
|
||||
createCustomToolRequestBody,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
executeRequest,
|
||||
formatRequestParams,
|
||||
getClientEnvVars,
|
||||
transformTable,
|
||||
validateRequiredParametersAfterMerge,
|
||||
} from '@/tools/utils'
|
||||
|
||||
@@ -91,6 +91,25 @@ describe('transformTable', () => {
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should parse JSON string inputs and transform rows', () => {
|
||||
const table = [
|
||||
{ id: '1', cells: { Key: 'city', Value: 'SF' } },
|
||||
{ id: '2', cells: { Key: 'temp', Value: 64 } },
|
||||
]
|
||||
const result = transformTable(JSON.stringify(table))
|
||||
|
||||
expect(result).toEqual({
|
||||
city: 'SF',
|
||||
temp: 64,
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should parse JSON string object inputs', () => {
|
||||
const result = transformTable(JSON.stringify({ a: 1, b: 'two' }))
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 'two' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRequestParams', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCustomToolsStore } from '@/stores/custom-tools'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { extractErrorMessage } from '@/tools/error-extractors'
|
||||
import { tools } from '@/tools/registry'
|
||||
import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('ToolsUtils')
|
||||
|
||||
@@ -70,30 +70,6 @@ export function resolveToolId(toolName: string): string {
|
||||
return toolName
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a table from the store format to a key-value object
|
||||
* @param table Array of table rows from the store
|
||||
* @returns Record of key-value pairs
|
||||
*/
|
||||
export const transformTable = (table: TableRow[] | null): Record<string, any> => {
|
||||
if (!table) return {}
|
||||
|
||||
return table.reduce(
|
||||
(acc, row) => {
|
||||
if (row.cells?.Key && row.cells?.Value !== undefined) {
|
||||
// Extract the Value cell as is - it should already be properly resolved
|
||||
// by the InputResolver based on variable type (number, string, boolean etc.)
|
||||
const value = row.cells.Value
|
||||
|
||||
// Store the correctly typed value in the result object
|
||||
acc[row.cells.Key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}
|
||||
|
||||
interface RequestParams {
|
||||
url: string
|
||||
method: string
|
||||
|
||||
Reference in New Issue
Block a user