Skip to content

Commit 84220af

Browse files
committed
Get handleSteps fully working from template files!
1 parent b815d07 commit 84220af

File tree

7 files changed

+497
-10
lines changed

7 files changed

+497
-10
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { DynamicAgentConfig } from '@codebuff/common/types/dynamic-agent-template'
2+
3+
export default {
4+
id: 'example-handlesteps-agent',
5+
version: '1.0.0',
6+
name: 'Example HandleSteps Agent',
7+
purpose:
8+
'Demonstrates how to use handleSteps generator functions for programmatic agent control',
9+
model: 'claude-3-5-sonnet-20241022',
10+
outputMode: 'json',
11+
toolNames: ['spawn_agents', 'set_output', 'end_turn'],
12+
spawnableAgents: ['file_picker'],
13+
14+
systemPrompt:
15+
'You are an example agent that demonstrates handleSteps functionality.',
16+
userInputPrompt: 'User request: {prompt}',
17+
agentStepPrompt: 'Continue processing the request.',
18+
19+
// Generator function that defines the agent's execution flow
20+
handleSteps: function* ({ agentState, prompt, params }) {
21+
// Step 1: Spawn a file picker to find relevant files
22+
const { toolResult: filePickerResult } = yield {
23+
toolName: 'spawn_agents',
24+
args: {
25+
agents: [
26+
{
27+
agent_type: 'file_picker',
28+
prompt: prompt || 'Find relevant files for the user request',
29+
},
30+
],
31+
},
32+
}
33+
34+
// Step 2: Process the results and extract file paths using regex
35+
let message = 'File picker completed'
36+
let files: string[] = []
37+
38+
if (filePickerResult) {
39+
// Extract the actual response from the agent_report wrapper
40+
const resultText = filePickerResult.result || ''
41+
42+
// Extract file paths from backticks in the text
43+
const filePathRegex = /`([^`]+\.[a-zA-Z0-9]+)`/g
44+
const matches: string[] = []
45+
let match
46+
47+
while ((match = filePathRegex.exec(resultText)) !== null) {
48+
const filePath: string | undefined = match[1]
49+
if (filePath && !matches.includes(filePath)) {
50+
matches.push(filePath)
51+
}
52+
}
53+
54+
if (matches.length > 0) {
55+
files = matches
56+
message = `Found ${files.length} file paths mentioned in response`
57+
} else {
58+
message = `File picker completed but no file paths found`
59+
}
60+
}
61+
62+
// Step 3: Set the final output
63+
yield {
64+
toolName: 'set_output',
65+
args: {
66+
message,
67+
files,
68+
prompt: prompt || 'No prompt provided',
69+
params: params || {},
70+
agentId: agentState.agentId,
71+
},
72+
}
73+
},
74+
} satisfies DynamicAgentConfig
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2+
import {
3+
DynamicAgentConfigSchema,
4+
DynamicAgentTemplate,
5+
} from '@codebuff/common/types/dynamic-agent-template'
6+
import { dynamicAgentService } from '../templates/dynamic-agent-service'
7+
import { AgentState } from '@codebuff/common/types/session-state'
8+
import { ProjectFileContext } from '@codebuff/common/util/file'
9+
10+
describe('handleSteps Parsing Tests', () => {
11+
let mockFileContext: ProjectFileContext
12+
let mockAgentTemplate: DynamicAgentTemplate
13+
14+
beforeEach(() => {
15+
dynamicAgentService.reset()
16+
17+
// Setup common mock data
18+
mockFileContext = {
19+
projectRoot: '/test',
20+
cwd: '/test',
21+
fileTree: [],
22+
fileTokenScores: {},
23+
knowledgeFiles: {},
24+
agentTemplates: {},
25+
gitChanges: {
26+
status: '',
27+
diff: '',
28+
diffCached: '',
29+
lastCommitMessages: '',
30+
},
31+
changesSinceLastChat: {},
32+
shellConfigFiles: {},
33+
systemInfo: {
34+
platform: 'test',
35+
shell: 'test',
36+
nodeVersion: 'test',
37+
arch: 'test',
38+
homedir: '/test',
39+
cpus: 1,
40+
},
41+
tokenCallers: {},
42+
}
43+
44+
mockAgentTemplate = {
45+
id: 'test-agent',
46+
version: '1.0.0',
47+
name: 'Test Agent',
48+
purpose: 'Testing',
49+
model: 'claude-3-5-sonnet-20241022',
50+
outputMode: 'json' as const,
51+
toolNames: ['set_output'],
52+
spawnableAgents: [],
53+
override: false as const,
54+
includeMessageHistory: true,
55+
systemPrompt: 'Test system prompt',
56+
userInputPrompt: 'Test user prompt',
57+
agentStepPrompt: 'Test agent step prompt',
58+
initialAssistantMessage: '',
59+
initialAssistantPrefix: '',
60+
stepAssistantMessage: '',
61+
stepAssistantPrefix: '',
62+
}
63+
})
64+
65+
afterEach(() => {
66+
dynamicAgentService.reset()
67+
})
68+
69+
test('should validate agent config with handleSteps function', () => {
70+
const agentConfig = {
71+
id: 'test-agent',
72+
version: '1.0.0',
73+
name: 'Test Agent',
74+
purpose: 'Testing handleSteps',
75+
model: 'claude-3-5-sonnet-20241022',
76+
outputMode: 'json' as const,
77+
toolNames: ['set_output'],
78+
systemPrompt: 'You are a test agent',
79+
userInputPrompt: 'Process: {prompt}',
80+
agentStepPrompt: 'Continue processing',
81+
handleSteps: function* ({
82+
agentState,
83+
prompt,
84+
params,
85+
}: {
86+
agentState: AgentState
87+
prompt?: string
88+
params?: any
89+
}) {
90+
yield {
91+
toolName: 'set_output',
92+
args: { message: 'Test completed' },
93+
}
94+
},
95+
}
96+
97+
const result = DynamicAgentConfigSchema.safeParse(agentConfig)
98+
expect(result.success).toBe(true)
99+
100+
if (result.success) {
101+
expect(typeof result.data.handleSteps).toBe('function')
102+
}
103+
})
104+
105+
test('should convert handleSteps function to string', async () => {
106+
const handleStepsFunction = function* ({
107+
agentState,
108+
prompt,
109+
params,
110+
}: {
111+
agentState: AgentState
112+
prompt?: string
113+
params?: any
114+
}) {
115+
yield {
116+
toolName: 'set_output',
117+
args: { message: 'Hello from generator' },
118+
}
119+
}
120+
121+
const agentTemplates = {
122+
'test-agent': {
123+
...mockAgentTemplate,
124+
handleSteps: handleStepsFunction.toString(),
125+
},
126+
}
127+
128+
const fileContext: ProjectFileContext = {
129+
...mockFileContext,
130+
agentTemplates,
131+
}
132+
133+
const result = await dynamicAgentService.loadAgents(fileContext)
134+
135+
expect(result.validationErrors).toHaveLength(0)
136+
expect(result.templates['test-agent']).toBeDefined()
137+
expect(typeof result.templates['test-agent'].handleSteps).toBe('string')
138+
})
139+
140+
test('should require set_output tool for handleSteps with json output mode', () => {
141+
const {
142+
DynamicAgentTemplateSchema,
143+
} = require('@codebuff/common/types/dynamic-agent-template')
144+
145+
const agentConfig = {
146+
id: 'test-agent',
147+
version: '1.0.0',
148+
name: 'Test Agent',
149+
purpose: 'Testing',
150+
model: 'claude-3-5-sonnet-20241022',
151+
outputMode: 'json' as const,
152+
toolNames: ['end_turn'], // Missing set_output
153+
spawnableAgents: [],
154+
systemPrompt: 'Test',
155+
userInputPrompt: 'Test',
156+
agentStepPrompt: 'Test',
157+
initialAssistantMessage: '',
158+
initialAssistantPrefix: '',
159+
stepAssistantMessage: '',
160+
stepAssistantPrefix: '',
161+
handleSteps:
162+
'function* () { yield { toolName: "set_output", args: {} } }',
163+
}
164+
165+
const result = DynamicAgentTemplateSchema.safeParse(agentConfig)
166+
expect(result.success).toBe(false)
167+
if (!result.success) {
168+
const errorMessage = result.error.issues[0]?.message || ''
169+
expect(errorMessage).toContain('set_output')
170+
}
171+
})
172+
173+
test('should validate that handleSteps is a generator function', async () => {
174+
const agentTemplates = {
175+
'test-agent': {
176+
...mockAgentTemplate,
177+
handleSteps: 'function () { return "not a generator" }', // Missing *
178+
},
179+
}
180+
181+
const fileContext: ProjectFileContext = {
182+
...mockFileContext,
183+
agentTemplates,
184+
}
185+
186+
const result = await dynamicAgentService.loadAgents(fileContext)
187+
188+
expect(result.validationErrors.length).toBeGreaterThan(0)
189+
expect(result.validationErrors[0].message).toContain('generator function')
190+
expect(result.validationErrors[0].message).toContain('function*')
191+
})
192+
193+
test('should verify loaded template handleSteps matches original function toString', async () => {
194+
// Create a generator function
195+
const originalFunction = function* ({
196+
agentState,
197+
prompt,
198+
params,
199+
}: {
200+
agentState: AgentState
201+
prompt?: string
202+
params?: any
203+
}) {
204+
yield {
205+
toolName: 'set_output',
206+
args: { message: 'Test output', data: params },
207+
}
208+
}
209+
210+
// Get the string representation
211+
const expectedStringified = originalFunction.toString()
212+
213+
// Create agent templates with the function
214+
const agentTemplates = {
215+
'test-agent': {
216+
...mockAgentTemplate,
217+
handleSteps: expectedStringified,
218+
},
219+
}
220+
221+
const fileContext: ProjectFileContext = {
222+
...mockFileContext,
223+
agentTemplates,
224+
}
225+
226+
// Load agents through the service
227+
const result = await dynamicAgentService.loadAgents(fileContext)
228+
229+
// Verify no validation errors
230+
expect(result.validationErrors).toHaveLength(0)
231+
expect(result.templates['test-agent']).toBeDefined()
232+
233+
// Verify the loaded template's handleSteps field matches the original toString
234+
expect(result.templates['test-agent'].handleSteps).toBe(expectedStringified)
235+
expect(typeof result.templates['test-agent'].handleSteps).toBe('string')
236+
})
237+
})

backend/src/templates/dynamic-agent-service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,18 @@ export class DynamicAgentService {
230230
}
231231
}
232232

233+
// Validate handleSteps if present
234+
if (content.handleSteps) {
235+
if (!content.handleSteps.includes('function*')) {
236+
this.validationErrors.push({
237+
filePath,
238+
message: `handleSteps must be a generator function: "function* (params) { ... }". Found: ${content.handleSteps.substring(0, 50)}...`,
239+
details: 'handleSteps should start with "function*" to be a valid generator function',
240+
})
241+
return
242+
}
243+
}
244+
233245
// Determine outputMode: default to 'json' if outputSchema is present, otherwise 'last_message'
234246
const outputMode =
235247
content.outputMode ?? (content.outputSchema ? 'json' : 'last_message')

0 commit comments

Comments
 (0)