Skip to content

Commit ae69833

Browse files
committed
feat: initial local agent validation
1 parent 5f83f19 commit ae69833

File tree

9 files changed

+1931
-32
lines changed

9 files changed

+1931
-32
lines changed

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/chat.tsx

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
import { TextAttributes } from '@opentui/core'
12
import { useRenderer, useTerminalDimensions } from '@opentui/react'
2-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
import open from 'open'
4+
import path from 'path'
5+
import React, {
6+
type ReactNode,
7+
useCallback,
8+
useEffect,
9+
useMemo,
10+
useRef,
11+
useState,
12+
} from 'react'
313
import stringWidth from 'string-width'
414
import { useShallow } from 'zustand/react/shallow'
515

616
import { AgentModeToggle } from './components/agent-mode-toggle'
717
import { LoginModal } from './components/login-modal'
18+
import { TerminalLink } from './components/terminal-link'
819
import {
920
MultilineInput,
1021
type MultilineInputHandle,
@@ -55,7 +66,18 @@ type AgentMessage = {
5566
}
5667

5768
export type ContentBlock =
58-
| { type: 'text'; content: string }
69+
| {
70+
type: 'text'
71+
content: string
72+
marginTop?: number
73+
marginBottom?: number
74+
}
75+
| {
76+
type: 'html'
77+
marginTop?: number
78+
marginBottom?: number
79+
render: (context: { textColor: string; theme: ChatTheme }) => ReactNode
80+
}
5981
| {
6082
type: 'tool'
6183
toolCallId: string
@@ -101,6 +123,7 @@ export const App = ({
101123
requireAuth,
102124
hasInvalidCredentials,
103125
loadedAgentsData,
126+
validationErrors,
104127
}: {
105128
initialPrompt: string | null
106129
agentId?: string
@@ -110,6 +133,7 @@ export const App = ({
110133
agents: Array<{ id: string; displayName: string }>
111134
agentsDir: string
112135
} | null
136+
validationErrors: Array<{ id: string; message: string }>
113137
}) => {
114138
const renderer = useRenderer()
115139
const { width: measuredWidth } = useTerminalDimensions()
@@ -215,19 +239,141 @@ export const App = ({
215239
})
216240
}
217241

218-
blocks.push(
219-
{
242+
blocks.push({
243+
type: 'text',
244+
content:
245+
'Codebuff can read and write files in this repository, and run terminal commands to help you build.',
246+
})
247+
248+
// Add validation errors if any exist
249+
if (validationErrors.length > 0) {
250+
const errorCount = validationErrors.length
251+
const errorHeader =
252+
errorCount === 1
253+
? '**⚠️ 1 agent has validation issues**'
254+
: `**⚠️ ${errorCount} agents have validation issues**`
255+
256+
// Add header
257+
blocks.push({
220258
type: 'text',
221-
content:
222-
'Codebuff can read and write files in this repository, and run terminal commands to help you build.',
223-
},
224-
{
225-
type: 'agent-list',
226-
id: agentListId,
227-
agents: loadedAgentsData.agents,
228-
agentsDir: loadedAgentsData.agentsDir,
229-
},
230-
)
259+
content: `\n${errorHeader}`,
260+
})
261+
262+
// Add each error as a separate, nicely formatted block
263+
const agentInfoById = new Map(
264+
loadedAgentsData.agents.map((agent) => [agent.id, agent]),
265+
)
266+
267+
const normalizeRelativePath = (filePath: string): string => {
268+
const relativeToAgentsDir = path.relative(
269+
loadedAgentsData.agentsDir,
270+
filePath,
271+
)
272+
const normalized = relativeToAgentsDir.replace(/\\/g, '/')
273+
return `.agents/${normalized}`
274+
}
275+
276+
validationErrors.forEach((error, errorIndex) => {
277+
// Extract just the key error message, removing verbose schema details
278+
let message = error.message
279+
.replace(/Agent "[^"]+"\s*(?:\([^)]+\))?\s*:\s*/, '')
280+
.replace(/Schema validation failed:\s*/i, '')
281+
.trim()
282+
283+
// If message starts with JSON array, extract first error
284+
if (message.startsWith('[')) {
285+
try {
286+
const errors = JSON.parse(message)
287+
if (Array.isArray(errors) && errors.length > 0) {
288+
const firstError = errors[0]
289+
// Get the field path and message
290+
const field = firstError.path?.join('.') || 'field'
291+
message = `${field}: ${firstError.message}`
292+
}
293+
} catch {
294+
// Keep original message if parsing fails
295+
}
296+
}
297+
298+
// Clean up common validation messages to be more user-friendly
299+
message = message
300+
.replace(/Invalid input: expected (\w+), received (\w+)/i, 'Expected $1, got $2')
301+
.replace(/Agent ID must contain only lowercase letters, numbers, and hyphens/i, 'ID must be lowercase with hyphens only')
302+
.replace(/Cannot specify both (\w+) and (\w+)\..*/i, 'Cannot use both $1 and $2')
303+
304+
// Take first line only and limit length
305+
message = message.split('\n')[0]
306+
if (message.length > 80) {
307+
message = message.substring(0, 77) + '...'
308+
}
309+
310+
const agentId = error.id.replace(/_\d+$/, '')
311+
const agentInfo = agentInfoById.get(agentId)
312+
const relativePath = agentInfo
313+
? normalizeRelativePath(agentInfo.filePath)
314+
: null
315+
316+
const fieldMatch = message.match(/^([^:]+):\s*(.+)$/)
317+
const fieldName = fieldMatch ? fieldMatch[1] : null
318+
const errorBody = fieldMatch ? fieldMatch[2] : message
319+
320+
blocks.push({
321+
type: 'html',
322+
marginTop: errorIndex === 0 ? 1 : 0,
323+
render: ({ textColor }) => (
324+
<box style={{ flexDirection: 'row', gap: 1, alignItems: 'center' }}>
325+
<text wrap style={{ fg: textColor }}>
326+
<span attributes={TextAttributes.BOLD}>{agentId}</span>
327+
</text>
328+
{relativePath ? (
329+
<TerminalLink
330+
text={`(${relativePath})`}
331+
containerStyle={{
332+
width: 'auto',
333+
flexDirection: 'row',
334+
alignItems: 'center',
335+
}}
336+
formatLines={(text) => [text]}
337+
/>
338+
) : null}
339+
</box>
340+
),
341+
})
342+
343+
blocks.push({
344+
type: 'html',
345+
marginBottom: 1,
346+
render: ({ textColor }) => (
347+
<text wrap style={{ fg: textColor, marginLeft: 2 }}>
348+
{fieldName ? (
349+
<>
350+
<span attributes={TextAttributes.ITALIC}>
351+
{`${fieldName}:`}
352+
</span>{' '}
353+
{errorBody}
354+
</>
355+
) : (
356+
errorBody
357+
)}
358+
</text>
359+
),
360+
})
361+
})
362+
363+
// Add closing instruction
364+
blocks.push({
365+
type: 'text',
366+
content: '*Fix these in your .agents directory.*',
367+
marginTop: 1,
368+
})
369+
}
370+
371+
blocks.push({
372+
type: 'agent-list',
373+
id: agentListId,
374+
agents: loadedAgentsData.agents,
375+
agentsDir: loadedAgentsData.agentsDir,
376+
})
231377

232378
const initialMessage: ChatMessage = {
233379
id: `system-loaded-agents-${Date.now()}`,

cli/src/components/message-block.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,18 +344,36 @@ export const MessageBlock = ({
344344
? renderStreamingMarkdown(rawNestedContent, markdownOptionsForLevel)
345345
: renderMarkdown(rawNestedContent, markdownOptionsForLevel)
346346
: rawNestedContent
347+
const marginBottom = nestedBlock.marginBottom ?? 0
347348
nodes.push(
348349
<text
349350
key={renderKey}
350351
wrap
351352
style={{
352353
fg: theme.agentText,
353354
marginLeft: Math.max(0, indentLevel * 2),
355+
marginBottom,
354356
}}
355357
>
356358
{renderedContent}
357359
</text>,
358360
)
361+
} else if (nestedBlock.type === 'html') {
362+
const marginTop = nestedBlock.marginTop ?? 0
363+
const marginBottom = nestedBlock.marginBottom ?? 0
364+
nodes.push(
365+
<box
366+
key={`${keyPrefix}-html-${nestedIdx}`}
367+
style={{
368+
flexDirection: 'column',
369+
gap: 0,
370+
marginTop,
371+
marginBottom,
372+
}}
373+
>
374+
{nestedBlock.render({ textColor: theme.agentText, theme })}
375+
</box>,
376+
)
359377
} else if (nestedBlock.type === 'tool') {
360378
const isLastBranch = !hasBranchAfter(nestedBlocks, nestedIdx)
361379
nodes.push(
@@ -417,12 +435,34 @@ export const MessageBlock = ({
417435
prevBlock &&
418436
(prevBlock.type === 'tool' || prevBlock.type === 'agent')
419437
? 0
420-
: 0
438+
: block.marginTop ?? 0
439+
const marginBottom = block.marginBottom ?? 0
421440
return (
422-
<text key={renderKey} wrap style={{ fg: textColor, marginTop }}>
441+
<text
442+
key={renderKey}
443+
wrap
444+
style={{ fg: textColor, marginTop, marginBottom }}
445+
>
423446
{renderedContent}
424447
</text>
425448
)
449+
} else if (block.type === 'html') {
450+
const marginTop = block.marginTop ?? 0
451+
const marginBottom = block.marginBottom ?? 0
452+
return (
453+
<box
454+
key={`${messageId}-html-${idx}`}
455+
style={{
456+
flexDirection: 'column',
457+
gap: 0,
458+
marginTop,
459+
marginBottom,
460+
width: '100%',
461+
}}
462+
>
463+
{block.render({ textColor, theme })}
464+
</box>
465+
)
426466
} else if (block.type === 'tool') {
427467
const isLastBranch = !hasBranchAfter(blocks, idx)
428468
return renderToolBranch(

cli/src/index.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
77
import { Command } from 'commander'
88
import React from 'react'
99

10+
import { validateAgents } from '@codebuff/sdk'
11+
1012
import { App } from './chat'
1113
import { getLoadedAgentsData } from './utils/local-agent-registry'
1214
import { getUserCredentials } from './utils/auth'
1315
import { clearLogFile } from './utils/logger'
16+
import { loadAgentDefinitions } from './utils/load-agent-definitions'
1417

1518
const require = createRequire(import.meta.url)
1619

@@ -74,6 +77,19 @@ if (clearLogs) {
7477

7578
const loadedAgentsData = getLoadedAgentsData()
7679

80+
// Validate local agents and capture any errors
81+
let validationErrors: Array<{ id: string; message: string }> = []
82+
if (loadedAgentsData) {
83+
const agentDefinitions = loadAgentDefinitions()
84+
const validationResult = await validateAgents(agentDefinitions, {
85+
remote: false, // Use local validation only for startup
86+
})
87+
88+
if (!validationResult.success) {
89+
validationErrors = validationResult.validationErrors
90+
}
91+
}
92+
7793
// Create QueryClient instance with CLI-optimized defaults
7894
const queryClient = new QueryClient({
7995
defaultOptions: {
@@ -122,6 +138,7 @@ const AppWithAsyncAuth = () => {
122138
requireAuth={requireAuth}
123139
hasInvalidCredentials={hasInvalidCredentials}
124140
loadedAgentsData={loadedAgentsData}
141+
validationErrors={validationErrors}
125142
/>
126143
)
127144
}

0 commit comments

Comments
 (0)