Skip to content

Commit 1241d25

Browse files
committed
feat: preserve subagent context during model fallback
When a fallback occurs during subagent orchestration, capture completed subagent messages before session.revert() destroys them. Inject them as context in the re-prompt so the fallback model can continue from where the previous model left off. Fixes Smart-Coders-HQ#12 (subagent progress lost on fallback)
1 parent f815d5c commit 1241d25

File tree

2 files changed

+146
-0
lines changed

2 files changed

+146
-0
lines changed

src/replay/orchestrator.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { resolveFallbackModel } from "../resolution/fallback-resolver.js";
77
import type { FallbackStore } from "../state/store.js";
88
import type { ErrorCategory, ModelKey, PluginConfig } from "../types.js";
99
import { convertPartsForPrompt } from "./message-converter.js";
10+
import { captureSubagentContext, snapshotsToPromptParts } from "./subagent-context.js";
1011

1112
type Client = PluginInput["client"];
1213

@@ -190,6 +191,14 @@ export async function attemptFallback(
190191
return { success: false, error: "abort failed" };
191192
}
192193

194+
// Step 1.5: Capture subagent context before revert destroys it
195+
const subagentSnapshots = captureSubagentContext(
196+
messageEntries,
197+
lastUserEntry.id,
198+
agentName,
199+
logger,
200+
);
201+
193202
// Step 2: Revert to before the failed message
194203
try {
195204
await client.session.revert({
@@ -221,6 +230,17 @@ export async function attemptFallback(
221230

222231
// Step 3: Re-prompt with fallback model
223232
const promptParts = convertPartsForPrompt(lastUserEntry.parts);
233+
234+
// Inject preserved subagent context if available
235+
if (subagentSnapshots.length > 0) {
236+
const contextParts = snapshotsToPromptParts(subagentSnapshots);
237+
promptParts.unshift(...contextParts);
238+
logger.info("subagent.context.injected", {
239+
sessionId,
240+
snapshotCount: subagentSnapshots.length,
241+
});
242+
}
243+
224244
if (promptParts.length === 0) {
225245
promptParts.push({ type: "text", text: "" });
226246
}

src/replay/subagent-context.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { Part } from "@opencode-ai/sdk";
2+
import type { Logger } from "../logging/logger.js";
3+
4+
interface MessageEntry {
5+
info?: {
6+
role?: unknown;
7+
id?: unknown;
8+
agent?: unknown;
9+
model?: { providerID: string; modelID: string };
10+
};
11+
parts?: unknown;
12+
}
13+
14+
export interface SubagentSnapshot {
15+
agentName: string;
16+
parts: Part[];
17+
messageId: string;
18+
}
19+
20+
/**
21+
* Captures completed subagent messages between the last user message and the end
22+
* of the message list. Only captures messages from agents different from the
23+
* primary session agent.
24+
*/
25+
export function captureSubagentContext(
26+
messageEntries: unknown[],
27+
lastUserMessageId: string,
28+
primaryAgentName: string | null,
29+
logger: Logger,
30+
): SubagentSnapshot[] {
31+
const snapshots: SubagentSnapshot[] = [];
32+
let foundLastUser = false;
33+
34+
for (const entry of messageEntries) {
35+
if (!entry || typeof entry !== "object") continue;
36+
37+
const e = entry as MessageEntry;
38+
const info = e.info;
39+
if (!info || typeof info !== "object") continue;
40+
41+
const id = typeof info.id === "string" ? info.id : null;
42+
if (!id) continue;
43+
44+
// Start capturing after we pass the last user message
45+
if (id === lastUserMessageId) {
46+
foundLastUser = true;
47+
continue;
48+
}
49+
if (!foundLastUser) continue;
50+
51+
// Only capture assistant messages (not user, not tool results)
52+
const role = typeof info.role === "string" ? info.role : null;
53+
if (role !== "assistant") continue;
54+
55+
// Only capture from different agents (subagents)
56+
const agentName = typeof info.agent === "string" ? info.agent : null;
57+
if (!agentName || agentName === primaryAgentName) continue;
58+
59+
// Capture the text parts
60+
const rawParts = Array.isArray(e.parts) ? e.parts : [];
61+
const textParts = rawParts.filter(
62+
(p): p is Part =>
63+
typeof p === "object" &&
64+
p !== null &&
65+
"type" in p &&
66+
(p as { type: string }).type === "text" &&
67+
"text" in p &&
68+
typeof (p as { text: unknown }).text === "string" &&
69+
((p as { text: string }).text.length > 0),
70+
);
71+
72+
if (textParts.length === 0) continue;
73+
74+
snapshots.push({
75+
agentName,
76+
parts: textParts as Part[],
77+
messageId: id,
78+
});
79+
80+
logger.debug("subagent.snapshot.captured", {
81+
agentName,
82+
messageId: id,
83+
partCount: textParts.length,
84+
});
85+
}
86+
87+
return snapshots;
88+
}
89+
90+
/**
91+
* Converts captured subagent snapshots into prompt parts that can be
92+
* injected after a revert to preserve context.
93+
*/
94+
export function snapshotsToPromptParts(
95+
snapshots: SubagentSnapshot[],
96+
): Array<{ type: "text"; text: string }> {
97+
if (snapshots.length === 0) return [];
98+
99+
const parts: Array<{ type: "text"; text: string }> = [];
100+
101+
parts.push({
102+
type: "text",
103+
text: "[CONTEXT PRESERVED - Previous subagent results recovered after model fallback]\n",
104+
});
105+
106+
for (const snapshot of snapshots) {
107+
const textContent = snapshot.parts
108+
.filter((p): p is Part & { type: "text"; text: string } =>
109+
typeof p === "object" && p !== null && "type" in p && p.type === "text" && "text" in p,
110+
)
111+
.map((p) => p.text)
112+
.join("\n");
113+
114+
parts.push({
115+
type: "text",
116+
text: `[Subagent: ${snapshot.agentName}]\n${textContent}\n`,
117+
});
118+
}
119+
120+
parts.push({
121+
type: "text",
122+
text: "[END PRESERVED CONTEXT]\n",
123+
});
124+
125+
return parts;
126+
}

0 commit comments

Comments
 (0)