Skip to content

Commit 1f14bba

Browse files
author
Eric Oliver
committed
api and cli are both outputting cleaner responses
1 parent a938851 commit 1f14bba

File tree

8 files changed

+1078
-44
lines changed

8 files changed

+1078
-44
lines changed

src/api/server/FastifyServer.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export class FastifyServer {
131131
const body = request.body as any
132132
const task = body.task || "No task specified"
133133
const mode = body.mode || "code"
134+
const verbose = body.verbose || false // Extract verbose flag from request
134135

135136
// Create job
136137
const job = this.jobManager.createJob(task, {
@@ -153,8 +154,8 @@ export class FastifyServer {
153154
// Create SSE stream
154155
const stream = this.streamManager.createStream(reply.raw, job.id)
155156

156-
// Create SSE adapter for this job
157-
const sseAdapter = new SSEOutputAdapter(this.streamManager, job.id)
157+
// Create SSE adapter for this job with verbose flag
158+
const sseAdapter = new SSEOutputAdapter(this.streamManager, job.id, verbose)
158159

159160
// Send initial start event
160161
await sseAdapter.emitStart("Task started", task)

src/api/streaming/MessageBuffer.ts

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/**
2+
* Content type classification for LLM output messages
3+
*/
4+
export type ContentType = "content" | "thinking" | "tool_call" | "tool_result" | "system"
5+
6+
/**
7+
* Processed message result from MessageBuffer
8+
*/
9+
export interface ProcessedMessage {
10+
content: string
11+
contentType: ContentType
12+
isComplete: boolean // For multi-part tool calls
13+
toolName?: string // When contentType is 'tool_call'
14+
}
15+
16+
/**
17+
* Internal buffer state for tracking XML parsing
18+
*/
19+
interface BufferState {
20+
buffer: string
21+
inThinkingSection: boolean
22+
inToolSection: boolean
23+
currentToolName: string | null
24+
tagStack: string[] // Track nested tags
25+
pendingContent: string // Content waiting for tag completion
26+
}
27+
28+
/**
29+
* MessageBuffer handles stateful processing of LLM output chunks that may contain
30+
* partial XML tags across multiple SSE events. It classifies content by type and
31+
* maintains parsing state to handle tags split across chunk boundaries.
32+
*/
33+
export class MessageBuffer {
34+
private state: BufferState
35+
36+
// Tool names that should be classified as tool_call content
37+
private static readonly TOOL_NAMES = new Set([
38+
"read_file",
39+
"write_to_file",
40+
"apply_diff",
41+
"search_files",
42+
"list_files",
43+
"list_code_definition_names",
44+
"execute_command",
45+
"browser_action",
46+
"insert_content",
47+
"search_and_replace",
48+
"ask_followup_question",
49+
"attempt_completion",
50+
"use_mcp_tool",
51+
"access_mcp_resource",
52+
"switch_mode",
53+
"new_task",
54+
"fetch_instructions",
55+
])
56+
57+
// System tags that should be classified as system content
58+
private static readonly SYSTEM_TAGS = new Set([
59+
"args",
60+
"path",
61+
"content",
62+
"line_count",
63+
"file",
64+
"files",
65+
"coordinate",
66+
"size",
67+
"text",
68+
"url",
69+
"action",
70+
"server_name",
71+
"tool_name",
72+
"arguments",
73+
"uri",
74+
"question",
75+
"follow_up",
76+
"suggest",
77+
"command",
78+
"cwd",
79+
"mode_slug",
80+
"reason",
81+
"mode",
82+
"message",
83+
"task",
84+
])
85+
86+
// Result tags that should be classified as tool_result content
87+
private static readonly RESULT_TAGS = new Set(["result", "error", "output", "response"])
88+
89+
constructor() {
90+
this.state = this.createInitialState()
91+
}
92+
93+
/**
94+
* Process a new chunk of content and return any complete messages
95+
*/
96+
processMessage(chunk: string): ProcessedMessage[] {
97+
const results: ProcessedMessage[] = []
98+
99+
// Add new chunk to buffer
100+
this.state.buffer += chunk
101+
102+
let processedIndex = 0
103+
104+
while (processedIndex < this.state.buffer.length) {
105+
const processed = this.processBufferFromIndex(processedIndex)
106+
107+
if (processed.message) {
108+
results.push(processed.message)
109+
}
110+
111+
if (processed.advanceBy === 0) {
112+
// No progress made, break to avoid infinite loop
113+
break
114+
}
115+
116+
processedIndex += processed.advanceBy
117+
}
118+
119+
// Remove processed content from buffer
120+
this.state.buffer = this.state.buffer.slice(processedIndex)
121+
122+
return results
123+
}
124+
125+
/**
126+
* Reset buffer state (call between tasks)
127+
*/
128+
reset(): void {
129+
this.state = this.createInitialState()
130+
}
131+
132+
/**
133+
* Get current buffered content (for debugging)
134+
*/
135+
getBufferedContent(): string {
136+
return this.state.buffer
137+
}
138+
139+
/**
140+
* Get current state (for debugging/testing)
141+
*/
142+
getState(): Readonly<BufferState> {
143+
return { ...this.state }
144+
}
145+
146+
private createInitialState(): BufferState {
147+
return {
148+
buffer: "",
149+
inThinkingSection: false,
150+
inToolSection: false,
151+
currentToolName: null,
152+
tagStack: [],
153+
pendingContent: "",
154+
}
155+
}
156+
157+
private processBufferFromIndex(startIndex: number): { message?: ProcessedMessage; advanceBy: number } {
158+
const remaining = this.state.buffer.slice(startIndex)
159+
160+
// Look for XML tag at current position
161+
const tagMatch = remaining.match(/^<(\/?[a-zA-Z_][a-zA-Z0-9_-]*)[^>]*>/)
162+
163+
if (tagMatch) {
164+
const fullTag = tagMatch[0]
165+
const tagNameWithSlash = tagMatch[1]
166+
const isClosingTag = tagNameWithSlash.startsWith("/")
167+
const tagName = isClosingTag ? tagNameWithSlash.slice(1) : tagNameWithSlash
168+
169+
// Process the tag and update state
170+
const tagResult = this.processTag(tagName, isClosingTag, fullTag)
171+
172+
return {
173+
message: tagResult.message,
174+
advanceBy: fullTag.length,
175+
}
176+
}
177+
178+
// No tag found, look for next character or potential partial tag
179+
const nextTagIndex = remaining.indexOf("<")
180+
181+
if (nextTagIndex === -1) {
182+
// No more tags in buffer, process all remaining content as current type
183+
if (remaining.length > 0) {
184+
const message = this.createContentMessage(remaining)
185+
return {
186+
message,
187+
advanceBy: remaining.length,
188+
}
189+
}
190+
return { advanceBy: 0 }
191+
}
192+
193+
if (nextTagIndex === 0) {
194+
// We're at a '<' but it didn't match as a complete tag
195+
// This might be a partial tag at buffer end
196+
const partialTag = remaining.match(/^<[^>]*$/)
197+
if (partialTag) {
198+
// Partial tag at end of buffer, wait for more content
199+
return { advanceBy: 0 }
200+
}
201+
// Invalid tag, skip the '<' character
202+
return { advanceBy: 1 }
203+
}
204+
205+
// Process content before next tag
206+
const contentBeforeTag = remaining.slice(0, nextTagIndex)
207+
const message = this.createContentMessage(contentBeforeTag)
208+
209+
return {
210+
message,
211+
advanceBy: nextTagIndex,
212+
}
213+
}
214+
215+
private processTag(tagName: string, isClosingTag: boolean, fullTag: string): { message?: ProcessedMessage } {
216+
// Handle thinking tags
217+
if (tagName === "thinking") {
218+
if (isClosingTag) {
219+
this.state.inThinkingSection = false
220+
this.state.tagStack = this.state.tagStack.filter((tag) => tag !== "thinking")
221+
} else {
222+
this.state.inThinkingSection = true
223+
this.state.tagStack.push("thinking")
224+
}
225+
return {}
226+
}
227+
228+
// Handle tool tags
229+
if (MessageBuffer.TOOL_NAMES.has(tagName)) {
230+
if (isClosingTag) {
231+
this.state.inToolSection = false
232+
this.state.currentToolName = null
233+
this.state.tagStack = this.state.tagStack.filter((tag) => tag !== tagName)
234+
} else {
235+
this.state.inToolSection = true
236+
this.state.currentToolName = tagName
237+
this.state.tagStack.push(tagName)
238+
}
239+
return {}
240+
}
241+
242+
// Handle system and result tags - these don't change parsing state
243+
// but we classify their content appropriately
244+
if (MessageBuffer.SYSTEM_TAGS.has(tagName) || MessageBuffer.RESULT_TAGS.has(tagName)) {
245+
// Don't change parsing state, just return tag as system/result content
246+
const contentType = MessageBuffer.RESULT_TAGS.has(tagName) ? "tool_result" : "system"
247+
248+
return {
249+
message: {
250+
content: fullTag,
251+
contentType,
252+
isComplete: true,
253+
},
254+
}
255+
}
256+
257+
// Unknown tag - treat as content
258+
return {
259+
message: {
260+
content: fullTag,
261+
contentType: "content",
262+
isComplete: true,
263+
},
264+
}
265+
}
266+
267+
private createContentMessage(content: string): ProcessedMessage {
268+
if (!content) {
269+
return {
270+
content: "",
271+
contentType: "content",
272+
isComplete: true,
273+
}
274+
}
275+
276+
// Determine content type based on current parsing state
277+
let contentType: ContentType
278+
let toolName: string | undefined
279+
280+
if (this.state.inThinkingSection) {
281+
contentType = "thinking"
282+
} else if (this.state.inToolSection) {
283+
contentType = "tool_call"
284+
toolName = this.state.currentToolName || undefined
285+
} else {
286+
contentType = "content"
287+
}
288+
289+
return {
290+
content,
291+
contentType,
292+
isComplete: true,
293+
toolName,
294+
}
295+
}
296+
}

0 commit comments

Comments
 (0)