Skip to content

Commit 8d39136

Browse files
committed
Implement xml tool call parsing and execution
1 parent 012cfbd commit 8d39136

File tree

11 files changed

+471
-617
lines changed

11 files changed

+471
-617
lines changed

.agents/editor/editor.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -103,44 +103,19 @@ More style notes:
103103
104104
Write out your complete implementation now, formatting all changes as tool calls as shown above.`,
105105

106-
handleSteps: function* ({ agentState: initialAgentState }) {
106+
handleSteps: function* ({ agentState: initialAgentState, logger }) {
107107
const initialMessageHistoryLength =
108108
initialAgentState.messageHistory.length
109109
const { agentState } = yield 'STEP'
110110
const { messageHistory } = agentState
111111

112112
const newMessages = messageHistory.slice(initialMessageHistoryLength)
113-
const assistantText = newMessages
114-
.filter((message) => message.role === 'assistant')
115-
.flatMap((message) => message.content)
116-
.filter((content) => content.type === 'text')
117-
.map((content) => content.text)
118-
.join('\n')
119-
120-
// Extract tool calls from the assistant text
121-
const toolCallsText = extractToolCallsOnly(assistantText)
122-
123-
const { agentState: postAssistantTextAgentState } = yield {
124-
type: 'STEP_TEXT',
125-
text: toolCallsText,
126-
} as StepText
127-
128-
const postAssistantTextMessageHistory =
129-
postAssistantTextAgentState.messageHistory.slice(
130-
initialMessageHistoryLength,
131-
)
132-
const toolResults = postAssistantTextMessageHistory
133-
.filter((message) => message.role === 'tool')
134-
.flatMap((message) => message.content)
135-
.filter((content) => content.type === 'json')
136-
.map((content) => content.value)
137113

138114
yield {
139115
toolName: 'set_output',
140116
input: {
141117
output: {
142-
message: toolCallsText,
143-
toolResults,
118+
messages: newMessages,
144119
},
145120
},
146121
includeToolCall: false,

cli/src/components/tools/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const toolComponentRegistry = new Map<ToolName, ToolComponent>([
3737
[SuggestFollowupsComponent.toolName, SuggestFollowupsComponent],
3838
[WriteFileComponent.toolName, WriteFileComponent],
3939
[TaskCompleteComponent.toolName, TaskCompleteComponent],
40+
// Propose tools reuse the same rendering as their base counterparts
41+
['propose_str_replace', StrReplaceComponent],
42+
['propose_write_file', WriteFileComponent],
4043
])
4144

4245
/**

cli/src/utils/sdk-event-handlers.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
} from './spawn-agent-matcher'
2323
import {
2424
destinationFromChunkEvent,
25-
destinationFromTextEvent,
2625
processTextChunk,
2726
} from './stream-chunk-processor'
2827

@@ -162,40 +161,6 @@ const updateStreamingAgents = (
162161
})
163162
}
164163

165-
const handleTextEvent = (state: EventHandlerState, event: PrintModeText) => {
166-
if (!event.text) {
167-
return
168-
}
169-
170-
ensureStreaming(state)
171-
172-
const destination = destinationFromTextEvent(event)
173-
const text = event.text
174-
175-
if (destination.type === 'agent') {
176-
const previous =
177-
state.streaming.streamRefs.state.agentStreamAccumulators.get(
178-
destination.agentId,
179-
) ?? ''
180-
state.streaming.streamRefs.setters.setAgentAccumulator(
181-
destination.agentId,
182-
previous + text,
183-
)
184-
state.message.updater.updateAiMessageBlocks((blocks) =>
185-
processTextChunk(blocks, destination, text),
186-
)
187-
return
188-
}
189-
190-
if (state.streaming.streamRefs.state.rootStreamSeen) {
191-
return
192-
}
193-
194-
state.streaming.streamRefs.setters.appendRootStreamBuffer(text)
195-
state.streaming.streamRefs.setters.setRootStreamSeen(true)
196-
appendRootChunk(state, { type: destination.textType, text })
197-
}
198-
199164
const handleSubagentStart = (
200165
state: EventHandlerState,
201166
event: PrintModeSubagentStart,
@@ -483,16 +448,6 @@ export const createStreamChunkHandler =
483448
return
484449
}
485450

486-
const previous =
487-
state.streaming.streamRefs.state.agentStreamAccumulators.get(
488-
destination.agentId,
489-
) ?? ''
490-
491-
state.streaming.streamRefs.setters.setAgentAccumulator(
492-
destination.agentId,
493-
previous + text,
494-
)
495-
496451
state.message.updater.updateAiMessageBlocks((blocks) =>
497452
processTextChunk(blocks, destination, text),
498453
)

common/src/tools/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const publishedTools = [
6161
'glob',
6262
'list_directory',
6363
'lookup_agent_info',
64+
'propose_str_replace',
65+
'propose_write_file',
6466
'read_docs',
6567
'read_files',
6668
'read_subtree',

packages/agent-runtime/src/tool-stream-parser.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
22

3+
import {
4+
createStreamParserState,
5+
parseStreamChunk,
6+
} from './util/stream-xml-parser'
7+
8+
import type { StreamParserState } from './util/stream-xml-parser'
39
import type { Model } from '@codebuff/common/old-constants'
410
import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics'
511
import type { StreamChunk } from '@codebuff/common/types/contracts/llm'
@@ -31,6 +37,11 @@ export async function* processStreamWithTools(params: {
3137
agentName?: string
3238
}
3339
trackEvent: TrackEventFn
40+
executeXmlToolCall: (params: {
41+
toolCallId: string
42+
toolName: string
43+
input: Record<string, unknown>
44+
}) => Promise<void>
3445
}): AsyncGenerator<StreamChunk, string | null> {
3546
const {
3647
stream,
@@ -41,11 +52,15 @@ export async function* processStreamWithTools(params: {
4152
logger,
4253
loggerOptions,
4354
trackEvent,
55+
executeXmlToolCall,
4456
} = params
4557
let streamCompleted = false
4658
let buffer = ''
4759
let autocompleted = false
4860

61+
// State for parsing XML tool calls from text stream
62+
const xmlParserState: StreamParserState = createStreamParserState()
63+
4964
function processToolCallObject(params: {
5065
toolName: string
5166
input: any
@@ -83,17 +98,48 @@ export async function* processStreamWithTools(params: {
8398
buffer = ''
8499
}
85100

86-
function* processChunk(
101+
async function* processChunk(
87102
chunk: StreamChunk | undefined,
88-
): Generator<StreamChunk> {
103+
): AsyncGenerator<StreamChunk> {
89104
if (chunk === undefined) {
90105
flush()
91106
streamCompleted = true
92107
return
93108
}
94109

95110
if (chunk.type === 'text') {
96-
buffer += chunk.text
111+
// Parse XML tool calls from the text stream
112+
const { filteredText, toolCalls } = parseStreamChunk(
113+
chunk.text,
114+
xmlParserState,
115+
)
116+
117+
if (filteredText) {
118+
buffer += filteredText
119+
yield {
120+
type: 'text',
121+
text: filteredText,
122+
}
123+
}
124+
125+
// Flush buffer before yielding tool calls so text event is sent first
126+
if (toolCalls.length > 0) {
127+
flush()
128+
}
129+
130+
// Then process and yield any XML tool calls found
131+
for (const toolCall of toolCalls) {
132+
const toolCallId = `xml-${crypto.randomUUID().slice(0, 8)}`
133+
134+
// Execute the tool immediately if callback provided, pausing the stream
135+
// The callback handles emitting tool_call and tool_result events
136+
await executeXmlToolCall({
137+
toolCallId,
138+
toolName: toolCall.toolName,
139+
input: toolCall.input,
140+
})
141+
}
142+
return
97143
} else {
98144
flush()
99145
}

packages/agent-runtime/src/tools/stream-parser.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ export async function processStream(
6464
> &
6565
ParamsExcluding<
6666
typeof processStreamWithTools,
67-
'processors' | 'defaultProcessor' | 'onError' | 'loggerOptions'
67+
| 'processors'
68+
| 'defaultProcessor'
69+
| 'onError'
70+
| 'loggerOptions'
71+
| 'executeXmlToolCall'
6872
>,
6973
) {
7074
const {
@@ -246,6 +250,26 @@ export async function processStream(
246250
}
247251
return onResponseChunk(chunk)
248252
},
253+
// Execute XML-parsed tool calls immediately during streaming
254+
executeXmlToolCall: async ({ toolName, input }) => {
255+
if (signal.aborted) {
256+
return
257+
}
258+
259+
// Cast input to the expected type - the XML parser produces Record<string, unknown>
260+
// but the callbacks expect Record<string, string>. The actual values are strings.
261+
const inputAsStrings = input as Record<string, string>
262+
263+
// Use the appropriate callback based on whether it's a native or custom tool
264+
const isNativeTool = toolNames.includes(toolName as ToolName)
265+
if (isNativeTool) {
266+
const callback = toolCallback(toolName as ToolName)
267+
await callback.onTagEnd(toolName, inputAsStrings)
268+
} else {
269+
const callback = customToolCallback(toolName)
270+
await callback.onTagEnd(toolName, inputAsStrings)
271+
}
272+
},
249273
})
250274

251275
let messageId: string | null = null

0 commit comments

Comments
 (0)