Skip to content

Commit 4b65831

Browse files
committed
type xml tag structures + return invalid id linter errors back to LLM
1 parent 92fd749 commit 4b65831

File tree

3 files changed

+249
-24
lines changed
  • apps/sim

3 files changed

+249
-24
lines changed
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
export type {
22
ContentSegment,
33
CredentialTagData,
4+
CredentialTagType,
5+
FileTagData,
6+
MothershipErrorTagData,
47
OptionsTagData,
58
ParsedSpecialContent,
9+
RuntimeSpecialTagName,
10+
UsageUpgradeAction,
611
UsageUpgradeTagData,
712
} from './special-tags'
8-
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
13+
export {
14+
CREDENTIAL_TAG_TYPES,
15+
PendingTagIndicator,
16+
parseFileTag,
17+
parseJsonTagBody,
18+
parseSpecialTags,
19+
parseTagAttributes,
20+
parseTextTagBody,
21+
SpecialTags,
22+
USAGE_UPGRADE_ACTIONS,
23+
} from './special-tags'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 177 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,51 @@ import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
88

99
export interface OptionsItemData {
1010
title: string
11-
description?: string
11+
description: string
1212
}
1313

14-
export type OptionsTagData = Record<string, OptionsItemData | string>
14+
export type OptionsTagData = Record<string, OptionsItemData>
1515

16+
export const USAGE_UPGRADE_ACTIONS = ['upgrade_plan', 'increase_limit'] as const
17+
18+
export type UsageUpgradeAction = (typeof USAGE_UPGRADE_ACTIONS)[number]
19+
20+
/**
21+
* Synthetic inline tag payload derived from request-layer HTTP upgrade/quota
22+
* failures and rendered through the same special-tag abstraction as streamed tags.
23+
*/
1624
export interface UsageUpgradeTagData {
1725
reason: string
18-
action: 'upgrade_plan' | 'increase_limit'
26+
action: UsageUpgradeAction
1927
message: string
2028
}
2129

30+
export const CREDENTIAL_TAG_TYPES = [
31+
'env_key',
32+
'oauth_key',
33+
'sim_key',
34+
'credential_id',
35+
'link',
36+
] as const
37+
38+
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
39+
2240
export interface CredentialTagData {
2341
value: string
24-
type: 'env_key' | 'oauth_key' | 'sim_key' | 'credential_id' | 'link'
42+
type: CredentialTagType
2543
provider?: string
2644
}
2745

2846
export interface MothershipErrorTagData {
2947
message: string
30-
code: string
31-
provider: string
48+
code?: string
49+
provider?: string
50+
}
51+
52+
export interface FileTagData {
53+
name: string
54+
type: string
55+
content: string
3256
}
3357

3458
export type ContentSegment =
@@ -39,11 +63,26 @@ export type ContentSegment =
3963
| { type: 'credential'; data: CredentialTagData }
4064
| { type: 'mothership-error'; data: MothershipErrorTagData }
4165

66+
export type RuntimeSpecialTagName =
67+
| 'thinking'
68+
| 'options'
69+
| 'credential'
70+
| 'mothership-error'
71+
| 'file'
72+
4273
export interface ParsedSpecialContent {
4374
segments: ContentSegment[]
4475
hasPendingTag: boolean
4576
}
4677

78+
const RUNTIME_SPECIAL_TAG_NAMES = [
79+
'thinking',
80+
'options',
81+
'credential',
82+
'mothership-error',
83+
'file',
84+
] as const
85+
4786
const SPECIAL_TAG_NAMES = [
4887
'thinking',
4988
'options',
@@ -52,6 +91,125 @@ const SPECIAL_TAG_NAMES = [
5291
'mothership-error',
5392
] as const
5493

94+
function isRecord(value: unknown): value is Record<string, unknown> {
95+
return typeof value === 'object' && value !== null
96+
}
97+
98+
function isOptionsItemData(value: unknown): value is OptionsItemData {
99+
if (!isRecord(value)) return false
100+
return typeof value.title === 'string' && typeof value.description === 'string'
101+
}
102+
103+
function isOptionsTagData(value: unknown): value is OptionsTagData {
104+
if (!isRecord(value)) return false
105+
return Object.values(value).every(isOptionsItemData)
106+
}
107+
108+
function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData {
109+
if (!isRecord(value)) return false
110+
return (
111+
typeof value.reason === 'string' &&
112+
typeof value.message === 'string' &&
113+
typeof value.action === 'string' &&
114+
(USAGE_UPGRADE_ACTIONS as readonly string[]).includes(value.action)
115+
)
116+
}
117+
118+
function isCredentialTagData(value: unknown): value is CredentialTagData {
119+
if (!isRecord(value)) return false
120+
return (
121+
typeof value.value === 'string' &&
122+
typeof value.type === 'string' &&
123+
(CREDENTIAL_TAG_TYPES as readonly string[]).includes(value.type) &&
124+
(value.provider === undefined || typeof value.provider === 'string')
125+
)
126+
}
127+
128+
function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData {
129+
if (!isRecord(value)) return false
130+
return (
131+
typeof value.message === 'string' &&
132+
(value.code === undefined || typeof value.code === 'string') &&
133+
(value.provider === undefined || typeof value.provider === 'string')
134+
)
135+
}
136+
137+
export function parseJsonTagBody<T>(
138+
body: string,
139+
isExpectedShape: (value: unknown) => value is T
140+
): T | null {
141+
try {
142+
const parsed = JSON.parse(body) as unknown
143+
return isExpectedShape(parsed) ? parsed : null
144+
} catch {
145+
return null
146+
}
147+
}
148+
149+
export function parseTextTagBody(body: string): string | null {
150+
return body.trim() ? body : null
151+
}
152+
153+
export function parseTagAttributes(openTag: string): Record<string, string> {
154+
const attributes: Record<string, string> = {}
155+
const attributePattern = /([A-Za-z_:][A-Za-z0-9_:-]*)="([^"]*)"/g
156+
157+
let match: RegExpExecArray | null = null
158+
while ((match = attributePattern.exec(openTag)) !== null) {
159+
attributes[match[1]] = match[2]
160+
}
161+
162+
return attributes
163+
}
164+
165+
export function parseFileTag(openTag: string, body: string): FileTagData | null {
166+
const attributes = parseTagAttributes(openTag)
167+
if (!attributes.name || !attributes.type) return null
168+
return {
169+
name: attributes.name,
170+
type: attributes.type,
171+
content: body,
172+
}
173+
}
174+
175+
function parseSpecialTagData(
176+
tagName: (typeof SPECIAL_TAG_NAMES)[number],
177+
body: string
178+
):
179+
| { type: 'thinking'; content: string }
180+
| { type: 'options'; data: OptionsTagData }
181+
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
182+
| { type: 'credential'; data: CredentialTagData }
183+
| { type: 'mothership-error'; data: MothershipErrorTagData }
184+
| null {
185+
if (tagName === 'thinking') {
186+
const content = parseTextTagBody(body)
187+
return content ? { type: 'thinking', content } : null
188+
}
189+
190+
if (tagName === 'options') {
191+
const data = parseJsonTagBody(body, isOptionsTagData)
192+
return data ? { type: 'options', data } : null
193+
}
194+
195+
if (tagName === 'usage_upgrade') {
196+
const data = parseJsonTagBody(body, isUsageUpgradeTagData)
197+
return data ? { type: 'usage_upgrade', data } : null
198+
}
199+
200+
if (tagName === 'credential') {
201+
const data = parseJsonTagBody(body, isCredentialTagData)
202+
return data ? { type: 'credential', data } : null
203+
}
204+
205+
if (tagName === 'mothership-error') {
206+
const data = parseJsonTagBody(body, isMothershipErrorTagData)
207+
return data ? { type: 'mothership-error', data } : null
208+
}
209+
210+
return null
211+
}
212+
55213
/**
56214
* Parses inline special tags (`<options>`, `<usage_upgrade>`) from streamed
57215
* text content. Complete tags are extracted into typed segments; incomplete
@@ -68,7 +226,7 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
68226

69227
while (cursor < content.length) {
70228
let nearestStart = -1
71-
let nearestTagName = ''
229+
let nearestTagName: (typeof SPECIAL_TAG_NAMES)[number] | '' = ''
72230

73231
for (const name of SPECIAL_TAG_NAMES) {
74232
const idx = content.indexOf(`<${name}>`, cursor)
@@ -85,7 +243,10 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
85243
const partial = remaining.match(/<[a-z_-]*$/i)
86244
if (partial) {
87245
const fragment = partial[0].slice(1)
88-
if (fragment.length > 0 && SPECIAL_TAG_NAMES.some((t) => t.startsWith(fragment))) {
246+
if (
247+
fragment.length > 0 &&
248+
[...SPECIAL_TAG_NAMES, ...RUNTIME_SPECIAL_TAG_NAMES].some((t) => t.startsWith(fragment))
249+
) {
89250
remaining = remaining.slice(0, -partial[0].length)
90251
hasPendingTag = true
91252
}
@@ -117,20 +278,13 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
117278
}
118279

119280
const body = content.slice(bodyStart, closeIdx)
120-
if (nearestTagName === 'thinking') {
121-
if (body.trim()) {
122-
segments.push({ type: 'thinking', content: body })
123-
}
124-
} else {
125-
try {
126-
const data = JSON.parse(body)
127-
segments.push({
128-
type: nearestTagName as 'options' | 'usage_upgrade' | 'credential' | 'mothership-error',
129-
data,
130-
})
131-
} catch {
132-
/* malformed JSON — drop the tag silently */
133-
}
281+
if (!nearestTagName) {
282+
cursor = closeIdx + closeTag.length
283+
continue
284+
}
285+
const parsedTag = parseSpecialTagData(nearestTagName, body)
286+
if (parsedTag) {
287+
segments.push(parsedTag)
134288
}
135289

136290
cursor = closeIdx + closeTag.length
@@ -211,7 +365,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
211365
<span className='font-base text-[14px] text-[var(--text-body)]'>Suggested follow-ups</span>
212366
<div className='mt-1.5 flex flex-col'>
213367
{entries.map(([key, value], i) => {
214-
const title = typeof value === 'string' ? value : value.title
368+
const title = value.title
215369

216370
return (
217371
<button

apps/sim/lib/copilot/orchestrator/tool-executor/index.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import { routeExecution } from '@/lib/copilot/tools/server/router'
1212
import { env } from '@/lib/core/config/env'
1313
import { getBaseUrl } from '@/lib/core/utils/urls'
1414
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
15+
import { getKnowledgeBaseById } from '@/lib/knowledge/service'
1516
import { validateMcpDomain } from '@/lib/mcp/domain-check'
1617
import { mcpService } from '@/lib/mcp/service'
1718
import { generateMcpServerId } from '@/lib/mcp/utils'
1819
import { getAllOAuthServices } from '@/lib/oauth/utils'
20+
import { getTableById } from '@/lib/table/service'
1921
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
2022
import {
2123
deleteCustomTool,
@@ -1066,6 +1068,60 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
10661068
title = record.name
10671069
}
10681070

1071+
if (resourceType === 'workflow') {
1072+
const workflow = await getWorkflowById(params.id)
1073+
if (!workflow) {
1074+
return {
1075+
success: false,
1076+
error: `No workflow with id "${params.id}". Confirm the workflow ID before opening it.`,
1077+
}
1078+
}
1079+
if (c.workspaceId && workflow.workspaceId !== c.workspaceId) {
1080+
return {
1081+
success: false,
1082+
error: `Workflow "${params.id}" was not found in the current workspace.`,
1083+
}
1084+
}
1085+
resourceId = workflow.id
1086+
title = workflow.name
1087+
}
1088+
1089+
if (resourceType === 'table') {
1090+
const table = await getTableById(params.id)
1091+
if (!table) {
1092+
return {
1093+
success: false,
1094+
error: `No table with id "${params.id}". Confirm the table ID before opening it.`,
1095+
}
1096+
}
1097+
if (c.workspaceId && table.workspaceId !== c.workspaceId) {
1098+
return {
1099+
success: false,
1100+
error: `Table "${params.id}" was not found in the current workspace.`,
1101+
}
1102+
}
1103+
resourceId = table.id
1104+
title = table.name
1105+
}
1106+
1107+
if (resourceType === 'knowledgebase') {
1108+
const knowledgeBase = await getKnowledgeBaseById(params.id)
1109+
if (!knowledgeBase) {
1110+
return {
1111+
success: false,
1112+
error: `No knowledge base with id "${params.id}". Confirm the knowledge base ID before opening it.`,
1113+
}
1114+
}
1115+
if (c.workspaceId && knowledgeBase.workspaceId !== c.workspaceId) {
1116+
return {
1117+
success: false,
1118+
error: `Knowledge base "${params.id}" was not found in the current workspace.`,
1119+
}
1120+
}
1121+
resourceId = knowledgeBase.id
1122+
title = knowledgeBase.name
1123+
}
1124+
10691125
return {
10701126
success: true,
10711127
output: { message: `Opened ${resourceType} ${resourceId} for the user` },

0 commit comments

Comments
 (0)