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:
Vikhyath Mondreti
2026-01-18 14:43:02 -08:00
committed by GitHub
parent 3a923648cb
commit 1dbf92db3f
9 changed files with 233 additions and 243 deletions

View File

@@ -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> = {

View File

@@ -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>
)
}

View File

@@ -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> = {

View File

@@ -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> = {

View File

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

View 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 {}
}

View 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),
}
}

View File

@@ -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', () => {

View File

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