Merge branch 'dev' into dependabot/npm_and_yarn/autogpt_platform/frontend/dev/faker-js/faker-10.0.0

This commit is contained in:
Nicholas Tindle
2025-09-30 15:24:45 -05:00
committed by GitHub
9 changed files with 588 additions and 0 deletions

View File

@@ -554,6 +554,89 @@ class AgentToggleInputBlock(AgentInputBlock):
)
class AgentTableInputBlock(AgentInputBlock):
"""
This block allows users to input data in a table format.
Configure the table columns at build time, then users can input
rows of data at runtime. Each row is output as a dictionary
with column names as keys.
"""
class Input(AgentInputBlock.Input):
value: Optional[list[dict[str, Any]]] = SchemaField(
description="The table data as a list of dictionaries.",
default=None,
advanced=False,
title="Default Value",
)
column_headers: list[str] = SchemaField(
description="Column headers for the table.",
default_factory=lambda: ["Column 1", "Column 2", "Column 3"],
advanced=False,
title="Column Headers",
)
def generate_schema(self):
"""Generate schema for the value field with table format."""
schema = super().generate_schema()
schema["type"] = "array"
schema["format"] = "table"
schema["items"] = {
"type": "object",
"properties": {
header: {"type": "string"}
for header in (
self.column_headers or ["Column 1", "Column 2", "Column 3"]
)
},
}
if self.value is not None:
schema["default"] = self.value
return schema
class Output(AgentInputBlock.Output):
result: list[dict[str, Any]] = SchemaField(
description="The table data as a list of dictionaries with headers as keys."
)
def __init__(self):
super().__init__(
id="5603b273-f41e-4020-af7d-fbc9c6a8d928",
description="Block for table data input with customizable headers.",
disabled=not config.enable_agent_input_subtype_blocks,
input_schema=AgentTableInputBlock.Input,
output_schema=AgentTableInputBlock.Output,
test_input=[
{
"name": "test_table",
"column_headers": ["Name", "Age", "City"],
"value": [
{"Name": "John", "Age": "30", "City": "New York"},
{"Name": "Jane", "Age": "25", "City": "London"},
],
"description": "Example table input",
}
],
test_output=[
(
"result",
[
{"Name": "John", "Age": "30", "City": "New York"},
{"Name": "Jane", "Age": "25", "City": "London"},
],
)
],
)
async def run(self, input_data: Input, *args, **kwargs) -> BlockOutput:
"""
Yields the table data as a list of dictionaries.
"""
# Pass through the value, defaulting to empty list if None
yield "result", input_data.value if input_data.value is not None else []
IO_BLOCK_IDs = [
AgentInputBlock().id,
AgentOutputBlock().id,
@@ -565,4 +648,5 @@ IO_BLOCK_IDs = [
AgentFileInputBlock().id,
AgentDropdownInputBlock().id,
AgentToggleInputBlock().id,
AgentTableInputBlock().id,
]

View File

@@ -101,6 +101,7 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
CLAUDE_4_1_OPUS = "claude-opus-4-1-20250805"
CLAUDE_4_OPUS = "claude-opus-4-20250514"
CLAUDE_4_SONNET = "claude-sonnet-4-20250514"
CLAUDE_4_5_SONNET = "claude-sonnet-4-5-20250929"
CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219"
CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest"
CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest"
@@ -213,6 +214,9 @@ MODEL_METADATA = {
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
"anthropic", 200000, 64000
), # claude-4-sonnet-20250514
LlmModel.CLAUDE_4_5_SONNET: ModelMetadata(
"anthropic", 200000, 64000
), # claude-sonnet-4-5-20250929
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
"anthropic", 200000, 64000
), # claude-3-7-sonnet-20250219

View File

@@ -0,0 +1,131 @@
import pytest
from backend.blocks.io import AgentTableInputBlock
from backend.util.test import execute_block_test
@pytest.mark.asyncio
async def test_table_input_block():
"""Test the AgentTableInputBlock with basic input/output."""
block = AgentTableInputBlock()
await execute_block_test(block)
@pytest.mark.asyncio
async def test_table_input_with_data():
"""Test AgentTableInputBlock with actual table data."""
block = AgentTableInputBlock()
input_data = block.Input(
name="test_table",
column_headers=["Name", "Age", "City"],
value=[
{"Name": "John", "Age": "30", "City": "New York"},
{"Name": "Jane", "Age": "25", "City": "London"},
{"Name": "Bob", "Age": "35", "City": "Paris"},
],
)
output_data = []
async for output_name, output_value in block.run(input_data):
output_data.append((output_name, output_value))
assert len(output_data) == 1
assert output_data[0][0] == "result"
result = output_data[0][1]
assert len(result) == 3
assert result[0]["Name"] == "John"
assert result[1]["Age"] == "25"
assert result[2]["City"] == "Paris"
@pytest.mark.asyncio
async def test_table_input_empty_data():
"""Test AgentTableInputBlock with empty data."""
block = AgentTableInputBlock()
input_data = block.Input(
name="empty_table", column_headers=["Col1", "Col2"], value=[]
)
output_data = []
async for output_name, output_value in block.run(input_data):
output_data.append((output_name, output_value))
assert len(output_data) == 1
assert output_data[0][0] == "result"
assert output_data[0][1] == []
@pytest.mark.asyncio
async def test_table_input_with_missing_columns():
"""Test AgentTableInputBlock passes through data with missing columns as-is."""
block = AgentTableInputBlock()
input_data = block.Input(
name="partial_table",
column_headers=["Name", "Age", "City"],
value=[
{"Name": "John", "Age": "30"}, # Missing City
{"Name": "Jane", "City": "London"}, # Missing Age
{"Age": "35", "City": "Paris"}, # Missing Name
],
)
output_data = []
async for output_name, output_value in block.run(input_data):
output_data.append((output_name, output_value))
result = output_data[0][1]
assert len(result) == 3
# Check data is passed through as-is
assert result[0] == {"Name": "John", "Age": "30"}
assert result[1] == {"Name": "Jane", "City": "London"}
assert result[2] == {"Age": "35", "City": "Paris"}
@pytest.mark.asyncio
async def test_table_input_none_value():
"""Test AgentTableInputBlock with None value returns empty list."""
block = AgentTableInputBlock()
input_data = block.Input(
name="none_table", column_headers=["Name", "Age"], value=None
)
output_data = []
async for output_name, output_value in block.run(input_data):
output_data.append((output_name, output_value))
assert len(output_data) == 1
assert output_data[0][0] == "result"
assert output_data[0][1] == []
@pytest.mark.asyncio
async def test_table_input_with_default_headers():
"""Test AgentTableInputBlock with default column headers."""
block = AgentTableInputBlock()
# Don't specify column_headers, should use defaults
input_data = block.Input(
name="default_headers_table",
value=[
{"Column 1": "A", "Column 2": "B", "Column 3": "C"},
{"Column 1": "D", "Column 2": "E", "Column 3": "F"},
],
)
output_data = []
async for output_name, output_value in block.run(input_data):
output_data.append((output_name, output_value))
assert len(output_data) == 1
assert output_data[0][0] == "result"
result = output_data[0][1]
assert len(result) == 2
assert result[0]["Column 1"] == "A"
assert result[1]["Column 3"] == "F"

View File

@@ -69,6 +69,7 @@ MODEL_COST: dict[LlmModel, int] = {
LlmModel.CLAUDE_4_1_OPUS: 21,
LlmModel.CLAUDE_4_OPUS: 21,
LlmModel.CLAUDE_4_SONNET: 5,
LlmModel.CLAUDE_4_5_SONNET: 9,
LlmModel.CLAUDE_3_7_SONNET: 5,
LlmModel.CLAUDE_3_5_SONNET: 4,
LlmModel.CLAUDE_3_5_HAIKU: 1, # $0.80 / $4.00

View File

@@ -270,6 +270,7 @@ def SchemaField(
min_length: Optional[int] = None,
max_length: Optional[int] = None,
discriminator: Optional[str] = None,
format: Optional[str] = None,
json_schema_extra: Optional[dict[str, Any]] = None,
) -> T:
if default is PydanticUndefined and default_factory is None:
@@ -285,6 +286,7 @@ def SchemaField(
"advanced": advanced,
"hidden": hidden,
"depends_on": depends_on,
"format": format,
**(json_schema_extra or {}),
}.items()
if v is not None

View File

@@ -25,6 +25,7 @@ import {
BlockIOSimpleTypeSubSchema,
BlockIOStringSubSchema,
BlockIOSubSchema,
BlockIOTableSubSchema,
DataType,
determineDataType,
} from "@/lib/autogpt-server-api/types";
@@ -56,6 +57,7 @@ import { LocalValuedInput } from "../../../../../components/__legacy__/ui/input"
import NodeHandle from "./NodeHandle";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
import { Switch } from "../../../../../components/atoms/Switch/Switch";
import { NodeTableInput } from "../../../../../components/node-table-input";
type NodeObjectInputTreeProps = {
nodeId: string;
@@ -106,6 +108,7 @@ const NodeObjectInputTree: FC<NodeObjectInputTreeProps> = ({
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
displayName={propSchema.title || beautifyString(propKey)}
parentContext={object}
/>
</div>
);
@@ -315,6 +318,7 @@ export const NodeGenericInputField: FC<{
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
className?: string;
displayName?: string;
parentContext?: { [key: string]: any };
}> = ({
nodeId,
propKey,
@@ -326,6 +330,7 @@ export const NodeGenericInputField: FC<{
handleInputClick,
className,
displayName,
parentContext,
}) => {
className = cn(className);
displayName ||= propSchema.title || beautifyString(propKey);
@@ -467,6 +472,28 @@ export const NodeGenericInputField: FC<{
/>
);
case DataType.TABLE:
const tableSchema = propSchema as BlockIOTableSubSchema;
// Extract headers from the schema's items properties
const headers = tableSchema.items?.properties
? Object.keys(tableSchema.items.properties)
: ["Column 1", "Column 2", "Column 3"];
return (
<NodeTableInput
nodeId={nodeId}
selfKey={propKey}
schema={tableSchema}
headers={headers}
rows={currentValue}
errors={errors}
connections={connections}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
className={className}
displayName={displayName}
/>
);
case DataType.ARRAY:
return (
<NodeArrayInput
@@ -480,6 +507,7 @@ export const NodeGenericInputField: FC<{
connections={connections}
handleInputChange={handleInputChange}
handleInputClick={handleInputClick}
parentContext={parentContext}
/>
);
@@ -894,6 +922,7 @@ const NodeArrayInput: FC<{
handleInputClick: NodeObjectInputTreeProps["handleInputClick"];
className?: string;
displayName?: string;
parentContext?: { [key: string]: any };
}> = ({
nodeId,
selfKey,
@@ -905,6 +934,7 @@ const NodeArrayInput: FC<{
handleInputClick,
className,
displayName,
parentContext: _parentContext,
}) => {
entries ??= schema.default;
if (!entries || !Array.isArray(entries)) entries = [];

View File

@@ -8,13 +8,17 @@ import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
import {
BlockIOObjectSubSchema,
BlockIOSubSchema,
BlockIOTableSubSchema,
DataType,
determineDataType,
TableRow,
} from "@/lib/autogpt-server-api/types";
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { useRunAgentInputs } from "./useRunAgentInputs";
import { Switch } from "@/components/atoms/Switch/Switch";
import { PlusIcon, XIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
/**
* A generic prop structure for the TypeBasedInput.
@@ -44,6 +48,7 @@ export function RunAgentInputs({
const { handleUploadFile, uploadProgress } = useRunAgentInputs();
const dataType = determineDataType(schema);
const baseId = String(schema.title ?? "input")
.replace(/\s+/g, "-")
.toLowerCase();
@@ -211,6 +216,101 @@ export function RunAgentInputs({
break;
}
case DataType.TABLE: {
// Render a simple table UI for the run modal
const tableSchema = schema as BlockIOTableSubSchema;
const headers = tableSchema.items?.properties
? Object.keys(tableSchema.items.properties)
: ["Column 1", "Column 2", "Column 3"];
const tableData: TableRow[] = Array.isArray(value) ? value : [];
const updateRow = (index: number, header: string, newValue: string) => {
const newData = [...tableData];
if (!newData[index]) {
newData[index] = {};
}
newData[index][header] = newValue;
onChange(newData);
};
const addRow = () => {
const newRow: TableRow = {};
headers.forEach((header) => {
newRow[header] = "";
});
onChange([...tableData, newRow]);
};
const removeRow = (index: number) => {
const newData = tableData.filter((_, i) => i !== index);
onChange(newData);
};
innerInputElement = (
<div className="w-full space-y-2">
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800">
{headers.map((header) => (
<th
key={header}
className="px-3 py-2 text-left font-medium"
>
{header}
</th>
))}
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{tableData.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t dark:border-gray-700">
{headers.map((header) => (
<td key={header} className="px-3 py-1">
<input
type="text"
value={String(row[header] || "")}
onChange={(e) =>
updateRow(rowIndex, header, e.target.value)
}
className="w-full rounded border px-2 py-1 dark:border-gray-700 dark:bg-gray-900"
placeholder={`Enter ${header}`}
/>
</td>
))}
<td className="px-3 py-1">
<Button
type="button"
variant="ghost"
size="small"
onClick={() => removeRow(rowIndex)}
className="h-8 w-8 p-0"
>
<XIcon className="h-4 w-4" weight="bold" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button
type="button"
variant="outline"
size="small"
onClick={addRow}
className="w-full"
>
<PlusIcon className="mr-2 h-4 w-4" weight="bold" />
Add Row
</Button>
</div>
);
break;
}
case DataType.SHORT_TEXT:
default:
innerInputElement = (

View File

@@ -0,0 +1,210 @@
import React, { FC, useCallback, useEffect, useState } from "react";
import { PlusIcon, XIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
import {
BlockIOTableSubSchema,
TableRow,
TableCellValue,
} from "@/lib/autogpt-server-api/types";
import { Input } from "./atoms/Input/Input";
import { Button } from "./atoms/Button/Button";
interface NodeTableInputProps {
/** Unique identifier for the node in the builder graph */
nodeId: string;
/** Key identifier for this specific input field within the node */
selfKey: string;
/** Schema definition for the table structure */
schema: BlockIOTableSubSchema;
/** Column headers for the table */
headers: string[];
/** Initial row data for the table */
rows?: TableRow[];
/** Validation errors mapped by field key */
errors: { [key: string]: string | undefined };
/** Graph connections between nodes in the builder */
connections: {
edge_id: string;
source: string;
sourceHandle: string;
target: string;
targetHandle: string;
}[];
/** Callback when table data changes */
handleInputChange: (key: string, value: TableRow[]) => void;
/** Callback when input field is clicked (for builder selection) */
handleInputClick: (key: string) => void;
/** Additional CSS classes */
className?: string;
/** Display name for the input field */
displayName?: string;
}
/**
* Table input component for the workflow builder interface.
*
* This component is specifically designed for use in the agent builder where users
* design workflows with connected nodes. It includes graph connection capabilities
* via NodeHandle and is tightly integrated with the builder's state management.
*
* @warning Do NOT use this component in runtime/execution contexts (like RunAgentInputs).
* For runtime table inputs, use a simpler implementation without builder-specific features.
*
* @example
* ```tsx
* <NodeTableInput
* nodeId="node-123"
* selfKey="table_data"
* schema={tableSchema}
* headers={["Name", "Value"]}
* rows={existingData}
* connections={graphConnections}
* handleInputChange={handleChange}
* handleInputClick={handleClick}
* errors={{}}
* />
* ```
*
* @see Used exclusively in: `/app/(platform)/build/components/legacy-builder/NodeInputs.tsx`
*/
export const NodeTableInput: FC<NodeTableInputProps> = ({
nodeId,
selfKey,
schema,
headers,
rows = [],
errors,
connections,
handleInputChange,
handleInputClick: _handleInputClick,
className,
displayName,
}) => {
const [tableData, setTableData] = useState<TableRow[]>(rows);
// Sync with parent state when rows change
useEffect(() => {
setTableData(rows);
}, [rows]);
const isConnected = (key: string) =>
connections.some((c) => c.targetHandle === key && c.target === nodeId);
const updateTableData = useCallback(
(newData: TableRow[]) => {
setTableData(newData);
handleInputChange(selfKey, newData);
},
[selfKey, handleInputChange],
);
const updateCell = (
rowIndex: number,
header: string,
value: TableCellValue,
) => {
const newData = [...tableData];
if (!newData[rowIndex]) {
newData[rowIndex] = {};
}
newData[rowIndex][header] = value;
updateTableData(newData);
};
const addRow = () => {
if (!headers || headers.length === 0) {
return;
}
const newRow: TableRow = {};
headers.forEach((header) => {
newRow[header] = "";
});
updateTableData([...tableData, newRow]);
};
const removeRow = (index: number) => {
const newData = tableData.filter((_, i) => i !== index);
updateTableData(newData);
};
return (
<div className={cn("w-full space-y-2", className)}>
<NodeHandle
title={displayName || selfKey}
keyName={selfKey}
schema={schema}
isConnected={isConnected(selfKey)}
isRequired={false}
side="left"
/>
{!isConnected(selfKey) && (
<div className="nodrag overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
{headers.map((header, index) => (
<th
key={index}
className="border border-gray-300 bg-gray-100 px-2 py-1 text-left text-sm font-medium dark:border-gray-600 dark:bg-gray-800"
>
{header}
</th>
))}
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{tableData.map((row, rowIndex) => (
<tr key={rowIndex}>
{headers.map((header, colIndex) => (
<td
key={colIndex}
className="border border-gray-300 p-1 dark:border-gray-600"
>
<Input
id={`${selfKey}-${rowIndex}-${header}`}
label={header}
type="text"
value={String(row[header] || "")}
onChange={(e) =>
updateCell(rowIndex, header, e.target.value)
}
className="h-8 w-full"
placeholder={`Enter ${header}`}
/>
</td>
))}
<td className="p-1">
<Button
variant="ghost"
size="small"
onClick={() => removeRow(rowIndex)}
className="h-8 w-8 p-0"
>
<XIcon />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<Button
className="mt-2 bg-gray-200 font-normal text-black hover:text-white dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
onClick={addRow}
size="small"
>
<PlusIcon className="mr-2" /> Add Row
</Button>
</div>
)}
{errors[selfKey] && (
<span className="text-sm text-red-500">{errors[selfKey]}</span>
)}
</div>
);
};

View File

@@ -58,6 +58,7 @@ export type BlockIOSimpleTypeSubSchema =
| BlockIOCredentialsSubSchema
| BlockIOKVSubSchema
| BlockIOArraySubSchema
| BlockIOTableSubSchema
| BlockIOStringSubSchema
| BlockIONumberSubSchema
| BlockIOBooleanSubSchema
@@ -78,6 +79,7 @@ export enum DataType {
OBJECT = "object",
KEY_VALUE = "key-value",
ARRAY = "array",
TABLE = "table",
}
export type BlockIOSubSchemaMeta = {
@@ -114,6 +116,20 @@ export type BlockIOArraySubSchema = BlockIOSubSchemaMeta & {
secret?: boolean;
};
// Table cell values are typically primitives
export type TableCellValue = string | number | boolean | null;
export type TableRow = Record<string, TableCellValue>;
export type BlockIOTableSubSchema = BlockIOSubSchemaMeta & {
type: "array";
format: "table";
items: BlockIOObjectSubSchema;
const?: TableRow[];
default?: TableRow[];
secret?: boolean;
};
export type BlockIOStringSubSchema = BlockIOSubSchemaMeta & {
type: "string";
enum?: string[];
@@ -192,6 +208,7 @@ type BlockIOCombinedTypeSubSchema = BlockIOSubSchemaMeta & {
anyOf: BlockIOSimpleTypeSubSchema[];
default?: string | number | boolean | null;
secret?: boolean;
format?: string; // For table format and other formats on anyOf schemas
}
| BlockIOOneOfSubSchema
| BlockIODiscriminatedOneOfSubSchema
@@ -1061,6 +1078,10 @@ function _handleSingleTypeSchema(subSchema: BlockIOSubSchema): DataType {
return DataType.NUMBER;
}
if (subSchema.type === "array") {
// Check for table format first
if ("format" in subSchema && subSchema.format === "table") {
return DataType.TABLE;
}
/** Commented code below since we haven't yet support rendering of a multi-select with array { items: enum } type */
// if ("items" in subSchema && subSchema.items && "enum" in subSchema.items) {
// return DataType.MULTI_SELECT; // array + enum => multi-select
@@ -1140,6 +1161,11 @@ export function determineDataType(schema: BlockIOSubSchema): DataType {
// (array | null)
if (types.includes("array") && types.includes("null")) {
// Check for table format on the parent schema (where anyOf is)
if ("format" in schema && schema.format === "table") {
return DataType.TABLE;
}
const arrSchema = schema.anyOf.find((s) => s.type === "array");
if (arrSchema) return _handleSingleTypeSchema(arrSchema);
return DataType.ARRAY;