Refactor agent loop (#4412)

This commit is contained in:
Dax
2025-11-17 10:57:18 -05:00
committed by GitHub
parent 9fd43ec616
commit a1214fff2e
22 changed files with 1297 additions and 1324 deletions

View File

@@ -142,6 +142,21 @@ export namespace MessageV2 {
})
export type AgentPart = z.infer<typeof AgentPart>
export const CompactionPart = PartBase.extend({
type: z.literal("compaction"),
}).meta({
ref: "CompactionPart",
})
export type CompactionPart = z.infer<typeof CompactionPart>
export const SubtaskPart = PartBase.extend({
type: z.literal("subtask"),
prompt: z.string(),
description: z.string(),
agent: z.string(),
})
export type SubtaskPart = z.infer<typeof SubtaskPart>
export const RetryPart = PartBase.extend({
type: z.literal("retry"),
attempt: z.number(),
@@ -277,6 +292,13 @@ export namespace MessageV2 {
diffs: Snapshot.FileDiff.array(),
})
.optional(),
agent: z.string(),
model: z.object({
providerID: z.string(),
modelID: z.string(),
}),
system: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
}).meta({
ref: "UserMessage",
})
@@ -285,6 +307,7 @@ export namespace MessageV2 {
export const Part = z
.discriminatedUnion("type", [
TextPart,
SubtaskPart,
ReasoningPart,
FilePart,
ToolPart,
@@ -294,6 +317,7 @@ export namespace MessageV2 {
PatchPart,
AgentPart,
RetryPart,
CompactionPart,
])
.meta({
ref: "Part",
@@ -334,6 +358,7 @@ export namespace MessageV2 {
write: z.number(),
}),
}),
finish: z.string().optional(),
}).meta({
ref: "AssistantMessage",
})
@@ -482,6 +507,11 @@ export namespace MessageV2 {
time: {
created: v1.metadata.time.created,
},
agent: "build",
model: {
providerID: "opencode",
modelID: "opencode",
},
}
const parts = v1.parts.flatMap((part): Part[] => {
const base = {
@@ -529,107 +559,107 @@ export namespace MessageV2 {
if (msg.parts.length === 0) continue
if (msg.info.role === "user") {
result.push({
const userMessage: UIMessage = {
id: msg.info.id,
role: "user",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
},
]
// text/plain and directory files are converted into text parts, ignore them
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory")
return [
{
type: "file",
url: part.url,
mediaType: part.mime,
filename: part.filename,
},
]
return []
}),
})
parts: [],
}
result.push(userMessage)
for (const part of msg.parts) {
if (part.type === "text")
userMessage.parts.push({
type: "text",
text: part.text,
})
// text/plain and directory files are converted into text parts, ignore them
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory")
userMessage.parts.push({
type: "file",
url: part.url,
mediaType: part.mime,
filename: part.filename,
})
if (part.type === "compaction") {
userMessage.parts.push({
type: "text",
text: "What did we do so far?",
})
}
if (part.type === "subtask") {
userMessage.parts.push({
type: "text",
text: "The following tool was executed by the user",
})
}
}
}
if (msg.info.role === "assistant") {
result.push({
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
if (part.type === "text")
return [
{
type: "text",
text: part.text,
providerMetadata: part.metadata,
},
]
if (part.type === "step-start")
return [
{
type: "step-start",
},
]
if (part.type === "tool") {
if (part.state.status === "completed") {
if (part.state.attachments?.length) {
result.push({
id: Identifier.ascending("message"),
role: "user",
parts: [
{
type: "text",
text: `Tool ${part.tool} returned an attachment:`,
},
...part.state.attachments.map((attachment) => ({
type: "file" as const,
url: attachment.url,
mediaType: attachment.mime,
filename: attachment.filename,
})),
],
})
}
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
callProviderMetadata: part.metadata,
},
]
parts: [],
}
result.push(assistantMessage)
for (const part of msg.parts) {
if (part.type === "text")
assistantMessage.parts.push({
type: "text",
text: part.text,
providerMetadata: part.metadata,
})
if (part.type === "step-start")
assistantMessage.parts.push({
type: "step-start",
})
if (part.type === "tool") {
if (part.state.status === "completed") {
if (part.state.attachments?.length) {
result.push({
id: Identifier.ascending("message"),
role: "user",
parts: [
{
type: "text",
text: `Tool ${part.tool} returned an attachment:`,
},
...part.state.attachments.map((attachment) => ({
type: "file" as const,
url: attachment.url,
mediaType: attachment.mime,
filename: attachment.filename,
})),
],
})
}
if (part.state.status === "error")
return [
{
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
callProviderMetadata: part.metadata,
},
]
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
callProviderMetadata: part.metadata,
})
}
if (part.type === "reasoning") {
return [
{
type: "reasoning",
text: part.text,
providerMetadata: part.metadata,
},
]
}
return []
}),
})
if (part.state.status === "error")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
callProviderMetadata: part.metadata,
})
}
if (part.type === "reasoning") {
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
providerMetadata: part.metadata,
})
}
}
}
}
@@ -671,9 +701,16 @@ export namespace MessageV2 {
export async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) {
const result = [] as MessageV2.WithParts[]
const completed = new Set<string>()
for await (const msg of stream) {
result.push(msg)
if (msg.info.role === "assistant" && msg.info.summary === true) break
if (
msg.info.role === "user" &&
completed.has(msg.info.id) &&
msg.parts.some((part) => part.type === "compaction")
)
break
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish) completed.add(msg.info.parentID)
}
result.reverse()
return result