Compare commits

...

2 Commits

Author SHA1 Message Date
Vikhyath Mondreti
7544557841 feat(langsmith): add langsmith tools for logging, output selector use tool-aware listing 2026-01-14 13:59:39 -08:00
Vikhyath Mondreti
3f1dccd6aa fix(batch-add): on batch add persist subblock values (#2819)
* fix(batch-add): on batch add persist subblock values

* consolidate merge subblock

* consolidate more code
2026-01-14 13:01:26 -08:00
25 changed files with 1177 additions and 329 deletions

View File

@@ -577,6 +577,17 @@ export const ServiceBlock: BlockConfig = {
See the `/add-trigger` skill for creating triggers.
## Icon Requirement
If the icon doesn't already exist in `@/components/icons.tsx`, **do NOT search for it yourself**. After completing the block, ask the user to provide the SVG:
```
The block is complete, but I need an icon for {Service}.
Please provide the SVG and I'll convert it to a React component.
You can usually find this in the service's brand/press kit page, or copy it from their website.
```
## Checklist Before Finishing
- [ ] All subBlocks have `id`, `title` (except switch), and `type`
@@ -588,4 +599,5 @@ See the `/add-trigger` skill for creating triggers.
- [ ] Tools.config.tool returns correct tool ID
- [ ] Outputs match tool outputs
- [ ] Block registered in registry.ts
- [ ] If icon missing: asked user to provide SVG
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread

View File

@@ -226,17 +226,26 @@ export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* SVG paths from brand assets */}
{/* SVG paths from user-provided SVG */}
</svg>
)
}
```
### Finding Icons
1. Check the service's brand/press kit page
2. Download SVG logo
3. Convert to React component
4. Ensure it accepts and spreads props
### Getting Icons
**Do NOT search for icons yourself.** At the end of implementation, ask the user to provide the SVG:
```
I've completed the integration. Before I can add the icon, please provide the SVG for {Service}.
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
```
Once the user provides the SVG:
1. Extract the SVG paths/content
2. Create a React component that spreads props
3. Ensure viewBox is preserved from the original SVG
## Step 5: Create Triggers (Optional)
@@ -405,6 +414,7 @@ If creating V2 versions (API-aligned outputs):
- [ ] If triggers: spread trigger subBlocks with `getTrigger()`
### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
- [ ] Icon spreads props correctly
@@ -433,11 +443,18 @@ You: I'll add the Stripe integration. Let me:
1. First, research the Stripe API using Context7
2. Create the tools for key operations (payments, subscriptions, etc.)
3. Create the block with operation dropdown
4. Add the Stripe icon
5. Register everything
6. Generate docs
4. Register everything
5. Generate docs
6. Ask you for the Stripe icon SVG
[Proceed with implementation...]
[After completing steps 1-5...]
I've completed the Stripe integration. Before I can add the icon, please provide the SVG for Stripe.
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
```
## Common Gotchas

View File

@@ -1853,6 +1853,14 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
)
}
export function LangsmithIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'>
<circle cx='12' cy='12' r='10' />
</svg>
)
}
export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 181' fill='none'>

View File

@@ -55,6 +55,7 @@ import {
JiraIcon,
JiraServiceManagementIcon,
KalshiIcon,
LangsmithIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
@@ -180,6 +181,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
jira_service_management: JiraServiceManagementIcon,
kalshi: KalshiIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -231,7 +233,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
thinking: BrainIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,

View File

@@ -0,0 +1,59 @@
---
title: LangSmith
description: Forward workflow runs to LangSmith for observability
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="langsmith"
color="#1B5DFF"
/>
## Usage Instructions
Send run data to LangSmith to trace executions, attach metadata, and monitor workflow performance.
## Tools
### `langsmith_create_run`
Forward a single run to LangSmith for ingestion.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LangSmith API key |
| `runId` | string | Yes | Unique run identifier |
| `name` | string | Yes | Run name |
| `runType` | string | Yes | Run type \(tool, chain, llm, retriever, embedding, prompt, parser\) |
| `startTime` | string | Yes | Run start time in ISO-8601 format |
| `endTime` | string | No | Run end time in ISO-8601 format |
#### Output
This tool does not produce any outputs.
### `langsmith_create_runs_batch`
Forward multiple runs to LangSmith in a single batch.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LangSmith API key |
| `post` | json | No | Array of new runs to ingest |
| `patch` | json | No | Array of runs to update/patch |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accepted` | boolean | Whether the batch was accepted for ingestion |
| `runIds` | array | Run identifiers provided in the request |

View File

@@ -52,6 +52,7 @@
"jira_service_management",
"kalshi",
"knowledge",
"langsmith",
"lemlist",
"linear",
"linkedin",
@@ -103,7 +104,6 @@
"supabase",
"tavily",
"telegram",
"thinking",
"tinybird",
"translate",
"trello",

View File

@@ -63,8 +63,3 @@ Execute SQL queries against Tinybird Pipes and Data Sources using the Query API.
| `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read \(only available with FORMAT JSON\) |
## Notes
- Category: `tools`
- Type: `tinybird`

View File

@@ -8,6 +8,7 @@ import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { getToolOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -128,6 +129,10 @@ export function OutputSelect({
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
const operationValue =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.operation?.value
: subBlockValues?.[block.id]?.operation
let outputsToProcess: Record<string, unknown> = {}
@@ -141,7 +146,12 @@ export function OutputSelect({
outputsToProcess = blockConfig?.outputs || {}
}
} else {
outputsToProcess = blockConfig?.outputs || {}
const toolOutputs =
blockConfig && typeof operationValue === 'string'
? getToolOutputs(blockConfig, operationValue)
: {}
outputsToProcess =
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
}
if (Object.keys(outputsToProcess).length === 0) return

View File

@@ -20,7 +20,13 @@ import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/blocks/block-outputs'
import {
getBlockOutputPaths,
getBlockOutputType,
getOutputPathsFromSchema,
getToolOutputPaths,
getToolOutputType,
} from '@/lib/workflows/blocks/block-outputs'
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler'
import type {
@@ -38,7 +44,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('TagDropdown')
@@ -68,6 +73,12 @@ interface TagDropdownProps {
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
}
interface TagComputationResult {
tags: string[]
variableInfoMap: Record<string, { type: string; id: string }>
blockTagGroups: BlockTagGroup[]
}
/**
* Checks if the tag trigger (`<`) should show the tag dropdown.
*
@@ -218,161 +229,6 @@ const getOutputTypeForPath = (
return 'any'
}
/**
* Recursively generates all output paths from an outputs schema.
*
* @remarks
* Traverses nested objects and arrays to build dot-separated paths
* for all leaf values in the schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of dot-separated paths to all output fields
*/
const generateOutputPaths = (outputs: Record<string, any>, prefix = ''): string[] => {
const paths: string[] = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
const hasNestedProperties =
((value.type === 'object' || value.type === 'json') && value.properties) ||
(value.type === 'array' && value.items?.properties) ||
(value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items))
if (!hasNestedProperties) {
paths.push(currentPath)
}
if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push(...generateOutputPaths(value.properties, currentPath))
} else if (value.type === 'array' && value.items?.properties) {
paths.push(...generateOutputPaths(value.items.properties, currentPath))
} else if (
value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items)
) {
paths.push(...generateOutputPaths(value.items, currentPath))
}
} else {
const subPaths = generateOutputPaths(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push(currentPath)
}
}
return paths
}
/**
* Recursively generates all output paths with their types from an outputs schema.
*
* @remarks
* Similar to generateOutputPaths but also captures the type information
* for each path, useful for displaying type hints in the UI.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of objects containing path and type for each output field
*/
const generateOutputPathsWithTypes = (
outputs: Record<string, any>,
prefix = ''
): Array<{ path: string; type: string }> => {
const paths: Array<{ path: string; type: string }> = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
paths.push(...subPaths)
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push({ path: currentPath, type: value.type })
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
paths.push(...subPaths)
} else {
paths.push({ path: currentPath, type: value.type })
}
} else {
const subPaths = generateOutputPathsWithTypes(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push({ path: currentPath, type: 'any' })
}
}
return paths
}
/**
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Array of output paths for the tool, or empty array on error
*/
const generateToolOutputPaths = (blockConfig: BlockConfig, operation: string): string[] => {
if (!blockConfig?.tools?.config?.tool) return []
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return []
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return []
return generateOutputPaths(toolConfig.outputs)
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
return []
}
}
/**
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
const getToolOutputType = (blockConfig: BlockConfig, operation: string, path: string): string => {
if (!blockConfig?.tools?.config?.tool) return 'any'
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return 'any'
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(toolConfig.outputs)
const matchingPath = pathsWithTypes.find((p) => p.path === path)
return matchingPath?.type || 'any'
} catch (error) {
logger.warn('Failed to get tool output type for path', { path, error })
return 'any'
}
}
/**
* Calculates the viewport position of the caret in a textarea/input.
*
@@ -601,14 +457,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
[inputValue, cursorPosition]
)
const emptyVariableInfoMap: Record<string, { type: string; id: string }> = {}
/**
* Computes tags, variable info, and block tag groups
*/
const { tags, variableInfoMap, blockTagGroups } = useMemo(() => {
const { tags, variableInfoMap, blockTagGroups } = useMemo<TagComputationResult>(() => {
if (activeSourceBlockId) {
const sourceBlock = blocks[activeSourceBlockId]
if (!sourceBlock) {
return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] }
}
const blockConfig = getBlock(sourceBlock.type)
@@ -619,7 +477,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockName = sourceBlock.name || sourceBlock.type
const normalizedBlockName = normalizeName(blockName)
const outputPaths = generateOutputPaths(mockConfig.outputs)
const outputPaths = getOutputPathsFromSchema(mockConfig.outputs)
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
const blockTagGroups: BlockTagGroup[] = [
@@ -632,9 +490,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
},
]
return { tags: blockTags, variableInfoMap: {}, blockTagGroups }
return { tags: blockTags, variableInfoMap: emptyVariableInfoMap, blockTagGroups }
}
return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] }
}
const blockName = sourceBlock.name || sourceBlock.type
@@ -777,7 +635,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
const toolOutputPaths = operationValue
? generateToolOutputPaths(blockConfig, operationValue)
? getToolOutputPaths(blockConfig, operationValue)
: []
if (toolOutputPaths.length > 0) {
@@ -810,12 +668,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
},
]
return { tags: blockTags, variableInfoMap: {}, blockTagGroups }
return { tags: blockTags, variableInfoMap: emptyVariableInfoMap, blockTagGroups }
}
const hasInvalidBlocks = Object.values(blocks).some((block) => !block || !block.type)
if (hasInvalidBlocks) {
return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] }
}
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
@@ -981,7 +839,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockName = accessibleBlock.name || accessibleBlock.type
const normalizedBlockName = normalizeName(blockName)
const outputPaths = generateOutputPaths(mockConfig.outputs)
const outputPaths = getOutputPathsFromSchema(mockConfig.outputs)
let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = ensureRootTag(blockTags, normalizedBlockName)
@@ -1109,7 +967,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
const toolOutputPaths = operationValue
? generateToolOutputPaths(blockConfig, operationValue)
? getToolOutputPaths(blockConfig, operationValue)
: []
if (toolOutputPaths.length > 0) {
@@ -1183,7 +1041,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const filteredTags = useMemo(() => {
if (!searchTerm) return tags
return tags.filter((tag) => tag.toLowerCase().includes(searchTerm))
return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm))
}, [tags, searchTerm])
const { variableTags, filteredBlockTagGroups } = useMemo(() => {

View File

@@ -172,7 +172,7 @@ async function executeWebhookJobInternal(
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
// Merge subblock states (matching workflow-execution pattern)
const mergedStates = mergeSubblockState(blocks, {})
const mergedStates = mergeSubblockState(blocks)
// Create serialized workflow
const serializer = new Serializer()

View File

@@ -0,0 +1,292 @@
import { LangsmithIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { LangsmithResponse } from '@/tools/langsmith/types'
export const LangsmithBlock: BlockConfig<LangsmithResponse> = {
type: 'langsmith',
name: 'LangSmith',
description: 'Forward workflow runs to LangSmith for observability',
longDescription:
'Send run data to LangSmith to trace executions, attach metadata, and monitor workflow performance.',
docsLink: 'https://docs.sim.ai/tools/langsmith',
category: 'tools',
bgColor: '#181C1E',
icon: LangsmithIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create Run', id: 'create_run' },
{ label: 'Create Runs Batch', id: 'create_runs_batch' },
],
value: () => 'create_run',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your LangSmith API key',
password: true,
required: true,
},
{
id: 'id',
title: 'Run ID',
type: 'short-input',
placeholder: 'Auto-generated if blank',
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'name',
title: 'Name',
type: 'short-input',
placeholder: 'Run name',
required: { field: 'operation', value: 'create_run' },
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'run_type',
title: 'Run Type',
type: 'dropdown',
options: [
{ label: 'Chain', id: 'chain' },
{ label: 'Tool', id: 'tool' },
{ label: 'LLM', id: 'llm' },
{ label: 'Retriever', id: 'retriever' },
{ label: 'Embedding', id: 'embedding' },
{ label: 'Prompt', id: 'prompt' },
{ label: 'Parser', id: 'parser' },
],
value: () => 'chain',
required: { field: 'operation', value: 'create_run' },
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'start_time',
title: 'Start Time',
type: 'short-input',
placeholder: '2025-01-01T12:00:00Z',
condition: { field: 'operation', value: 'create_run' },
value: () => new Date().toISOString(),
},
{
id: 'end_time',
title: 'End Time',
type: 'short-input',
placeholder: '2025-01-01T12:00:30Z',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'inputs',
title: 'Inputs',
type: 'code',
placeholder: '{"input":"value"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'outputs',
title: 'Outputs',
type: 'code',
placeholder: '{"output":"value"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'extra',
title: 'Metadata',
type: 'code',
placeholder: '{"ls_model":"gpt-4"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'tags',
title: 'Tags',
type: 'code',
placeholder: '["production","workflow"]',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'parent_run_id',
title: 'Parent Run ID',
type: 'short-input',
placeholder: 'Parent run identifier',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'trace_id',
title: 'Trace ID',
type: 'short-input',
placeholder: 'Auto-generated if blank',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'session_id',
title: 'Session ID',
type: 'short-input',
placeholder: 'Session identifier',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'session_name',
title: 'Session Name',
type: 'short-input',
placeholder: 'Session name',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'status',
title: 'Status',
type: 'short-input',
placeholder: 'success',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'error',
title: 'Error',
type: 'long-input',
placeholder: 'Error message',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'dotted_order',
title: 'Dotted Order',
type: 'short-input',
placeholder: 'Defaults to <YYYYMMDDTHHMMSSffffff>Z<id>',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'events',
title: 'Events',
type: 'code',
placeholder: '[{"event":"token","value":1}]',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'post',
title: 'Post Runs',
type: 'code',
placeholder: '[{"id":"...","name":"...","run_type":"chain","start_time":"..."}]',
condition: { field: 'operation', value: 'create_runs_batch' },
},
{
id: 'patch',
title: 'Patch Runs',
type: 'code',
placeholder: '[{"id":"...","name":"...","run_type":"chain","start_time":"..."}]',
condition: { field: 'operation', value: 'create_runs_batch' },
mode: 'advanced',
},
],
tools: {
access: ['langsmith_create_run', 'langsmith_create_runs_batch'],
config: {
tool: (params) => {
switch (params.operation) {
case 'create_runs_batch':
return 'langsmith_create_runs_batch'
case 'create_run':
default:
return 'langsmith_create_run'
}
},
params: (params) => {
const parseJsonValue = (value: unknown, label: string) => {
if (value === undefined || value === null || value === '') {
return undefined
}
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch (error) {
throw new Error(
`Invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
return value
}
if (params.operation === 'create_runs_batch') {
const post = parseJsonValue(params.post, 'post runs')
const patch = parseJsonValue(params.patch, 'patch runs')
if (!post && !patch) {
throw new Error('Provide at least one of post or patch runs')
}
return {
apiKey: params.apiKey,
post,
patch,
}
}
return {
apiKey: params.apiKey,
id: params.id,
name: params.name,
run_type: params.run_type,
start_time: params.start_time,
end_time: params.end_time,
inputs: parseJsonValue(params.inputs, 'inputs'),
outputs: parseJsonValue(params.outputs, 'outputs'),
extra: parseJsonValue(params.extra, 'metadata'),
tags: parseJsonValue(params.tags, 'tags'),
parent_run_id: params.parent_run_id,
trace_id: params.trace_id,
session_id: params.session_id,
session_name: params.session_name,
status: params.status,
error: params.error,
dotted_order: params.dotted_order,
events: parseJsonValue(params.events, 'events'),
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'LangSmith API key' },
id: { type: 'string', description: 'Run identifier' },
name: { type: 'string', description: 'Run name' },
run_type: { type: 'string', description: 'Run type' },
start_time: { type: 'string', description: 'Run start time (ISO)' },
end_time: { type: 'string', description: 'Run end time (ISO)' },
inputs: { type: 'json', description: 'Run inputs payload' },
outputs: { type: 'json', description: 'Run outputs payload' },
extra: { type: 'json', description: 'Additional metadata (extra)' },
tags: { type: 'json', description: 'Tags array' },
parent_run_id: { type: 'string', description: 'Parent run ID' },
trace_id: { type: 'string', description: 'Trace ID' },
session_id: { type: 'string', description: 'Session ID' },
session_name: { type: 'string', description: 'Session name' },
status: { type: 'string', description: 'Run status' },
error: { type: 'string', description: 'Error message' },
dotted_order: { type: 'string', description: 'Dotted order string' },
events: { type: 'json', description: 'Events array' },
post: { type: 'json', description: 'Runs to ingest in batch' },
patch: { type: 'json', description: 'Runs to update in batch' },
},
outputs: {
accepted: { type: 'boolean', description: 'Whether ingestion was accepted' },
runId: { type: 'string', description: 'Run ID for single run' },
runIds: { type: 'array', description: 'Run IDs for batch ingest' },
message: { type: 'string', description: 'LangSmith response message' },
messages: { type: 'array', description: 'Per-run response messages' },
},
}

View File

@@ -61,6 +61,7 @@ import { JiraBlock } from '@/blocks/blocks/jira'
import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management'
import { KalshiBlock } from '@/blocks/blocks/kalshi'
import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
import { LangsmithBlock } from '@/blocks/blocks/langsmith'
import { LemlistBlock } from '@/blocks/blocks/lemlist'
import { LinearBlock } from '@/blocks/blocks/linear'
import { LinkedInBlock } from '@/blocks/blocks/linkedin'
@@ -217,6 +218,7 @@ export const registry: Record<string, BlockConfig> = {
jira_service_management: JiraServiceManagementBlock,
kalshi: KalshiBlock,
knowledge: KnowledgeBlock,
langsmith: LangsmithBlock,
lemlist: LemlistBlock,
linear: LinearBlock,
linkedin: LinkedInBlock,

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,4 @@
import { createLogger } from '@sim/logger'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import {
classifyStartBlockType,
@@ -12,8 +13,11 @@ import {
} from '@/lib/workflows/types'
import { getBlock } from '@/blocks'
import type { BlockConfig, OutputCondition, OutputFieldDefinition } from '@/blocks/types'
import { getTool } from '@/tools/utils'
import { getTrigger, isTriggerValid } from '@/triggers'
const logger = createLogger('BlockOutputs')
type OutputDefinition = Record<string, OutputFieldDefinition>
interface SubBlockWithValue {
@@ -435,3 +439,167 @@ export function getBlockOutputType(
const value = traverseOutputPath(outputs, pathParts)
return extractType(value)
}
/**
* Recursively generates all output paths from an outputs schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of dot-separated paths to all output fields
*/
function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[] {
const paths: string[] = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
const hasNestedProperties =
((value.type === 'object' || value.type === 'json') && value.properties) ||
(value.type === 'array' && value.items?.properties) ||
(value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items))
if (!hasNestedProperties) {
paths.push(currentPath)
}
if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push(...generateOutputPaths(value.properties, currentPath))
} else if (value.type === 'array' && value.items?.properties) {
paths.push(...generateOutputPaths(value.items.properties, currentPath))
} else if (
value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items)
) {
paths.push(...generateOutputPaths(value.items, currentPath))
}
} else {
const subPaths = generateOutputPaths(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push(currentPath)
}
}
return paths
}
/**
* Recursively generates all output paths with their types from an outputs schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of objects containing path and type for each output field
*/
function generateOutputPathsWithTypes(
outputs: Record<string, any>,
prefix = ''
): Array<{ path: string; type: string }> {
const paths: Array<{ path: string; type: string }> = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
paths.push(...subPaths)
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push({ path: currentPath, type: value.type })
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
paths.push(...subPaths)
} else {
paths.push({ path: currentPath, type: value.type })
}
} else {
const subPaths = generateOutputPathsWithTypes(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push({ path: currentPath, type: 'any' })
}
}
return paths
}
/**
* Gets the tool outputs for a block operation.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Outputs schema for the tool, or empty object on error
*/
export function getToolOutputs(blockConfig: BlockConfig, operation: string): Record<string, any> {
if (!blockConfig?.tools?.config?.tool) return {}
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return {}
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return {}
return toolConfig.outputs
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
return {}
}
}
/**
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Array of output paths for the tool, or empty array on error
*/
export function getToolOutputPaths(blockConfig: BlockConfig, operation: string): string[] {
const outputs = getToolOutputs(blockConfig, operation)
if (!outputs || Object.keys(outputs).length === 0) return []
return generateOutputPaths(outputs)
}
/**
* Generates output paths from a schema definition.
*
* @param outputs - The outputs schema object
* @returns Array of dot-separated paths to all output fields
*/
export function getOutputPathsFromSchema(outputs: Record<string, any>): string[] {
return generateOutputPaths(outputs)
}
/**
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
export function getToolOutputType(
blockConfig: BlockConfig,
operation: string,
path: string
): string {
const outputs = getToolOutputs(blockConfig, operation)
if (!outputs || Object.keys(outputs).length === 0) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(outputs)
const matchingPath = pathsWithTypes.find((p) => p.path === path)
return matchingPath?.type || 'any'
}

View File

@@ -0,0 +1,80 @@
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
export const DEFAULT_SUBBLOCK_TYPE = 'short-input'
/**
* Merges subblock values into the provided subblock structures.
* Falls back to a default subblock shape when a value has no structure.
* @param subBlocks - Existing subblock definitions from the workflow
* @param values - Stored subblock values keyed by subblock id
* @returns Merged subblock structures with updated values
*/
export function mergeSubBlockValues(
subBlocks: Record<string, unknown> | undefined,
values: Record<string, unknown> | undefined
): Record<string, unknown> {
const merged = { ...(subBlocks || {}) } as Record<string, any>
if (!values) return merged
Object.entries(values).forEach(([subBlockId, value]) => {
if (merged[subBlockId] && typeof merged[subBlockId] === 'object') {
merged[subBlockId] = {
...(merged[subBlockId] as Record<string, unknown>),
value,
}
return
}
merged[subBlockId] = {
id: subBlockId,
type: DEFAULT_SUBBLOCK_TYPE,
value,
}
})
return merged
}
/**
* Merges workflow block states with explicit subblock values while maintaining block structure.
* Values that are null or undefined do not override existing subblock values.
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated subblocks
*/
export function mergeSubblockStateWithValues(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, unknown>> = {},
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
return acc
}
const blockSubBlocks = block.subBlocks || {}
const blockValues = subBlockValues[id] || {}
const filteredValues = Object.fromEntries(
Object.entries(blockValues).filter(([, value]) => value !== null && value !== undefined)
)
const mergedSubBlocks = mergeSubBlockValues(blockSubBlocks, filteredValues) as Record<
string,
SubBlockState
>
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
}

View File

@@ -7,6 +7,7 @@ import postgres from 'postgres'
import { env } from '@/lib/core/config/env'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { mergeSubBlockValues } from '@/lib/workflows/subblocks'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
@@ -455,7 +456,7 @@ async function handleBlocksOperationTx(
}
case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: {
const { blocks, edges, loops, parallels } = payload
const { blocks, edges, loops, parallels, subBlockValues } = payload
logger.info(`Batch adding blocks to workflow ${workflowId}`, {
blockCount: blocks?.length || 0,
@@ -465,22 +466,30 @@ async function handleBlocksOperationTx(
})
if (blocks && blocks.length > 0) {
const blockValues = blocks.map((block: Record<string, unknown>) => ({
id: block.id as string,
workflowId,
type: block.type as string,
name: block.name as string,
positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
data: (block.data as Record<string, unknown>) || {},
subBlocks: (block.subBlocks as Record<string, unknown>) || {},
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}))
const blockValues = blocks.map((block: Record<string, unknown>) => {
const blockId = block.id as string
const mergedSubBlocks = mergeSubBlockValues(
block.subBlocks as Record<string, unknown>,
subBlockValues?.[blockId]
)
return {
id: blockId,
workflowId,
type: block.type as string,
name: block.name as string,
positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
data: (block.data as Record<string, unknown>) || {},
subBlocks: mergedSubBlocks,
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}
})
await tx.insert(workflowBlocks).values(blockValues)

View File

@@ -8,7 +8,8 @@
* or React hooks, making it safe for use in Next.js API routes.
*/
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Server-safe version of mergeSubblockState for API routes
@@ -26,72 +27,7 @@ export function mergeSubblockState(
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
// Skip if block is undefined
if (!block) {
return acc
}
// Initialize subBlocks if not present
const blockSubBlocks = block.subBlocks || {}
// Get stored values for this block
const blockValues = subBlockValues[id] || {}
// Create a deep copy of the block's subBlocks to maintain structure
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
(subAcc, [subBlockId, subBlock]) => {
// Skip if subBlock is undefined
if (!subBlock) {
return subAcc
}
// Get the stored value for this subblock
const storedValue = blockValues[subBlockId]
// Create a new subblock object with the same structure but updated value
subAcc[subBlockId] = {
...subBlock,
value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value,
}
return subAcc
},
{} as Record<string, SubBlockState>
)
// Return the full block state with updated subBlocks
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
// Add any values that exist in the provided values but aren't in the block structure
// This handles cases where block config has been updated but values still exist
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
// Create a minimal subblock structure
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input', // Default type that's safe to use
value: value,
}
}
})
// Update the block with the final merged subBlocks (including orphaned values)
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
}
/**

View File

@@ -1,20 +1,7 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false
return !currentEdges.some(
(e) =>
e.source === edge.source &&
e.sourceHandle === edge.sourceHandle &&
e.target === edge.target &&
e.targetHandle === edge.targetHandle
)
})
}
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -32,6 +19,19 @@ const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
export { normalizeName }
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false
return !currentEdges.some(
(e) =>
e.source === edge.source &&
e.sourceHandle === edge.sourceHandle &&
e.target === edge.target &&
e.targetHandle === edge.targetHandle
)
})
}
export interface RegeneratedState {
blocks: Record<string, BlockState>
edges: Edge[]
@@ -201,27 +201,20 @@ export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOp
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
const mergedSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in mergedSubBlocks) {
delete mergedSubBlocks[field]
if (field in baseSubBlocks) {
delete baseSubBlocks[field]
}
})
Object.entries(filteredSubBlockValues).forEach(([subblockId, value]) => {
if (mergedSubBlocks[subblockId]) {
mergedSubBlocks[subblockId].value = value as SubBlockState['value']
} else {
mergedSubBlocks[subblockId] = {
id: subblockId,
type: 'short-input',
value: value as SubBlockState['value'],
}
}
})
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
string,
SubBlockState
>
const block: BlockState = {
id: newId,
@@ -256,11 +249,16 @@ export function mergeSubblockState(
workflowId?: string,
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
const subBlockStore = useSubBlockStore.getState()
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
if (workflowId) {
return mergeSubblockStateWithValues(blocks, workflowSubblockValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
@@ -339,9 +337,15 @@ export async function mergeSubblockStateAsync(
workflowId?: string,
blockId?: string
): Promise<Record<string, BlockState>> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
const subBlockStore = useSubBlockStore.getState()
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
// Process blocks in parallel for better performance
const processedBlockEntries = await Promise.all(
Object.entries(blocksToProcess).map(async ([id, block]) => {
@@ -358,16 +362,7 @@ export async function mergeSubblockStateAsync(
return null
}
let storedValue = null
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId]
if (workflowValues?.[id]) {
storedValue = workflowValues[id][subBlockId]
}
} else {
storedValue = subBlockStore.getValue(id, subBlockId)
}
const storedValue = subBlockStore.getValue(id, subBlockId)
return [
subBlockId,
@@ -386,23 +381,6 @@ export async function mergeSubblockStateAsync(
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
) as Record<string, SubBlockState>
// Add any values that exist in the store but aren't in the block structure
// This handles cases where block config has been updated but values still exist
// IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc.
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId]
const blockValues = workflowValues?.[id] || {}
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input',
value: value as SubBlockState['value'],
}
}
})
}
// Return the full block state with updated subBlocks (including orphaned values)
return [
id,

View File

@@ -639,7 +639,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
const newName = getUniqueBlockName(block.name, get().blocks)
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({
@@ -668,7 +669,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
parallels: get().generateParallelBlocks(),
}
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}

View File

@@ -0,0 +1,188 @@
import type { LangsmithCreateRunParams, LangsmithCreateRunResponse } from '@/tools/langsmith/types'
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
import type { ToolConfig } from '@/tools/types'
export const langsmithCreateRunTool: ToolConfig<
LangsmithCreateRunParams,
LangsmithCreateRunResponse
> = {
id: 'langsmith_create_run',
name: 'LangSmith Create Run',
description: 'Forward a single run to LangSmith for ingestion.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'LangSmith API key',
},
id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Unique run identifier',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Run name',
},
run_type: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Run type (tool, chain, llm, retriever, embedding, prompt, parser)',
},
start_time: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run start time in ISO-8601 format',
},
end_time: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run end time in ISO-8601 format',
},
inputs: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Inputs payload',
},
outputs: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Outputs payload',
},
extra: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Additional metadata (extra)',
},
tags: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of tag strings',
},
parent_run_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Parent run ID',
},
trace_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Trace ID',
},
session_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Session ID',
},
session_name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Session name',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run status',
},
error: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Error details',
},
dotted_order: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Dotted order string',
},
events: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Structured events array',
},
},
request: {
url: () => 'https://api.smith.langchain.com/runs',
method: 'POST',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
body: (params) => {
const { payload } = normalizeLangsmithRunPayload(params)
const normalizedPayload: Record<string, unknown> = {
...payload,
name: payload.name?.trim(),
inputs: params.inputs,
outputs: params.outputs,
extra: params.extra,
tags: params.tags,
status: params.status,
error: params.error,
events: params.events,
}
return Object.fromEntries(
Object.entries(normalizedPayload).filter(([, value]) => value !== undefined)
)
},
},
transformResponse: async (response, params) => {
const runId = params ? normalizeLangsmithRunPayload(params).runId : null
const data = (await response.json()) as Record<string, unknown>
const directMessage =
typeof (data as { message?: unknown }).message === 'string'
? (data as { message: string }).message
: null
const nestedPayload =
runId && typeof data[runId] === 'object' && data[runId] !== null
? (data[runId] as Record<string, unknown>)
: null
const nestedMessage =
nestedPayload && typeof nestedPayload.message === 'string' ? nestedPayload.message : null
return {
success: true,
output: {
accepted: true,
runId: runId ?? null,
message: directMessage ?? nestedMessage ?? null,
},
}
},
outputs: {
accepted: {
type: 'boolean',
description: 'Whether the run was accepted for ingestion',
},
runId: {
type: 'string',
description: 'Run identifier provided in the request',
optional: true,
},
message: {
type: 'string',
description: 'Response message from LangSmith',
optional: true,
},
},
}

View File

@@ -0,0 +1,112 @@
import type {
LangsmithCreateRunsBatchParams,
LangsmithCreateRunsBatchResponse,
LangsmithRunPayload,
} from '@/tools/langsmith/types'
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
import type { ToolConfig } from '@/tools/types'
export const langsmithCreateRunsBatchTool: ToolConfig<
LangsmithCreateRunsBatchParams,
LangsmithCreateRunsBatchResponse
> = {
id: 'langsmith_create_runs_batch',
name: 'LangSmith Create Runs Batch',
description: 'Forward multiple runs to LangSmith in a single batch.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'LangSmith API key',
},
post: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of new runs to ingest',
},
patch: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of runs to update/patch',
},
},
request: {
url: () => 'https://api.smith.langchain.com/runs/batch',
method: 'POST',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
body: (params) => {
const payload: Record<string, unknown> = {
post: params.post
? params.post.map((run) => normalizeLangsmithRunPayload(run).payload)
: undefined,
patch: params.patch
? params.patch.map((run) => normalizeLangsmithRunPayload(run).payload)
: undefined,
}
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined))
},
},
transformResponse: async (response, params) => {
const data = (await response.json()) as Record<string, unknown>
const directMessage =
typeof (data as { message?: unknown }).message === 'string'
? (data as { message: string }).message
: null
const messages = Object.values(data)
.map((value) => {
if (typeof value !== 'object' || value === null) {
return null
}
const messageValue = (value as Record<string, unknown>).message
return typeof messageValue === 'string' ? messageValue : null
})
.filter((value): value is string => Boolean(value))
const collectRunIds = (runs?: LangsmithRunPayload[]) =>
runs?.map((run) => normalizeLangsmithRunPayload(run).runId) ?? []
return {
success: true,
output: {
accepted: true,
runIds: [...collectRunIds(params?.post), ...collectRunIds(params?.patch)],
message: directMessage ?? null,
messages: messages.length ? messages : undefined,
},
}
},
outputs: {
accepted: {
type: 'boolean',
description: 'Whether the batch was accepted for ingestion',
},
runIds: {
type: 'array',
description: 'Run identifiers provided in the request',
items: {
type: 'string',
},
},
message: {
type: 'string',
description: 'Response message from LangSmith',
optional: true,
},
messages: {
type: 'array',
description: 'Per-run response messages, when provided',
optional: true,
items: {
type: 'string',
},
},
},
}

View File

@@ -0,0 +1,2 @@
export { langsmithCreateRunTool } from '@/tools/langsmith/create_run'
export { langsmithCreateRunsBatchTool } from '@/tools/langsmith/create_runs_batch'

View File

@@ -0,0 +1,59 @@
import type { ToolResponse } from '@/tools/types'
export type LangsmithRunType =
| 'tool'
| 'chain'
| 'llm'
| 'retriever'
| 'embedding'
| 'prompt'
| 'parser'
export interface LangsmithRunPayload {
id?: string
name: string
run_type: LangsmithRunType
start_time?: string
end_time?: string
inputs?: Record<string, unknown>
outputs?: Record<string, unknown>
extra?: Record<string, unknown>
tags?: string[]
parent_run_id?: string
trace_id?: string
session_id?: string
session_name?: string
status?: string
error?: string
dotted_order?: string
events?: Record<string, unknown>[]
}
export interface LangsmithCreateRunParams extends LangsmithRunPayload {
apiKey: string
}
export interface LangsmithCreateRunsBatchParams {
apiKey: string
post?: LangsmithRunPayload[]
patch?: LangsmithRunPayload[]
}
export interface LangsmithCreateRunResponse extends ToolResponse {
output: {
accepted: boolean
runId: string | null
message: string | null
}
}
export interface LangsmithCreateRunsBatchResponse extends ToolResponse {
output: {
accepted: boolean
runIds: string[]
message: string | null
messages?: string[]
}
}
export type LangsmithResponse = LangsmithCreateRunResponse | LangsmithCreateRunsBatchResponse

View File

@@ -0,0 +1,38 @@
import type { LangsmithRunPayload } from '@/tools/langsmith/types'
interface NormalizedRunPayload {
payload: LangsmithRunPayload
runId: string
}
const toCompactTimestamp = (startTime?: string): string => {
const parsed = startTime ? new Date(startTime) : new Date()
const date = Number.isNaN(parsed.getTime()) ? new Date() : parsed
const pad = (value: number, length: number) => value.toString().padStart(length, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1, 2)
const day = pad(date.getUTCDate(), 2)
const hours = pad(date.getUTCHours(), 2)
const minutes = pad(date.getUTCMinutes(), 2)
const seconds = pad(date.getUTCSeconds(), 2)
const micros = pad(date.getUTCMilliseconds() * 1000, 6)
return `${year}${month}${day}T${hours}${minutes}${seconds}${micros}`
}
export const normalizeLangsmithRunPayload = (run: LangsmithRunPayload): NormalizedRunPayload => {
const runId = run.id ?? crypto.randomUUID()
const traceId = run.trace_id ?? runId
const startTime = run.start_time ?? new Date().toISOString()
const dottedOrder = run.dotted_order ?? `${toCompactTimestamp(startTime)}Z${runId}`
return {
runId,
payload: {
...run,
id: runId,
trace_id: traceId,
start_time: startTime,
dotted_order: dottedOrder,
},
}
}

View File

@@ -653,6 +653,7 @@ import {
knowledgeSearchTool,
knowledgeUploadChunkTool,
} from '@/tools/knowledge'
import { langsmithCreateRunsBatchTool, langsmithCreateRunTool } from '@/tools/langsmith'
import { lemlistGetActivitiesTool, lemlistGetLeadTool, lemlistSendEmailTool } from '@/tools/lemlist'
import {
linearAddLabelToIssueTool,
@@ -2442,6 +2443,8 @@ export const tools: Record<string, ToolConfig> = {
linear_update_project_status: linearUpdateProjectStatusTool,
linear_delete_project_status: linearDeleteProjectStatusTool,
linear_list_project_statuses: linearListProjectStatusesTool,
langsmith_create_run: langsmithCreateRunTool,
langsmith_create_runs_batch: langsmithCreateRunsBatchTool,
lemlist_get_activities: lemlistGetActivitiesTool,
lemlist_get_lead: lemlistGetLeadTool,
lemlist_send_email: lemlistSendEmailTool,