feat: include subagent trajectories in chat save and bug reports

This commit is contained in:
Aishanee Shah
2026-05-11 20:45:34 +00:00
parent 36a7fa089c
commit 69b336245c
9 changed files with 213 additions and 6 deletions
+8
View File
@@ -39,6 +39,7 @@ import {
import {
ChatRecordingService,
type ResumedSessionData,
type ConversationRecord,
} from '../services/chatRecordingService.js';
import {
ContentRetryEvent,
@@ -1230,6 +1231,13 @@ export class GeminiChat {
return this.chatRecordingService;
}
/**
* Gets all subagent trajectories associated with this chat session.
*/
async getSubagentTrajectories(): Promise<Record<string, ConversationRecord>> {
return this.chatRecordingService.getSubagentTrajectories();
}
/**
* Records completed tool calls with full metadata.
* This is called by external components when tool calls complete, before sending responses to Gemini.
+2
View File
@@ -11,6 +11,7 @@ import type { AuthType } from './contentGenerator.js';
import type { Storage } from '../config/storage.js';
import { debugLogger } from '../utils/debugLogger.js';
import { coreEvents } from '../utils/events.js';
import type { ConversationRecord } from '../services/chatRecordingService.js';
const LOG_FILE_NAME = 'logs.json';
@@ -29,6 +30,7 @@ export interface LogEntry {
export interface Checkpoint {
history: readonly Content[];
authType?: AuthType;
trajectories?: Record<string, ConversationRecord>;
}
// This regex matches any character that is NOT a letter (a-z, A-Z),
@@ -1315,4 +1315,111 @@ describe('ChatRecordingService', () => {
mkdirSyncSpy.mockRestore();
});
});
describe('getSubagentTrajectories', () => {
it('should recursively collect subagent trajectories', async () => {
await chatRecordingService.initialize();
// Setup a main conversation with a subagent call
const subagentId = 'sub-1';
chatRecordingService.recordToolCalls('gemini-pro', [
{
id: 'call-1',
name: 'invoke_agent',
args: { agent_name: 'test-agent', prompt: 'test' },
status: CoreToolCallStatus.Success,
timestamp: new Date().toISOString(),
agentId: subagentId,
},
]);
// Mock the subagent session file
const tempDir = mockConfig.storage.getProjectTempDir();
const subagentDir = path.join(tempDir, 'chats', 'test-session-id');
const subagentFile = path.join(subagentDir, `${subagentId}.jsonl`);
await fs.promises.mkdir(subagentDir, { recursive: true });
// Subagent conversation has another subagent call
const subSubagentId = 'sub-2';
const subagentConversation: ConversationRecord = {
sessionId: subagentId,
projectHash: 'mocked-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
kind: 'subagent',
messages: [
{
id: 'msg-1',
type: 'gemini',
timestamp: new Date().toISOString(),
content: [],
toolCalls: [
{
id: 'call-2',
name: 'invoke_agent',
args: { agent_name: 'inner-agent', prompt: 'inner' },
status: CoreToolCallStatus.Success,
timestamp: new Date().toISOString(),
agentId: subSubagentId,
},
],
},
],
};
await fs.promises.writeFile(
subagentFile,
JSON.stringify(subagentConversation) + '\n',
);
// Mock the sub-subagent session file
const subSubagentDir = path.join(tempDir, 'chats', subagentId);
const subSubagentFile = path.join(
subSubagentDir,
`${subSubagentId}.jsonl`,
);
await fs.promises.mkdir(subSubagentDir, { recursive: true });
const subSubagentConversation: ConversationRecord = {
sessionId: subSubagentId,
projectHash: 'mocked-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
kind: 'subagent',
messages: [
{
id: 'msg-2',
type: 'gemini',
timestamp: new Date().toISOString(),
content: [{ text: 'done' }],
},
],
};
await fs.promises.writeFile(
subSubagentFile,
JSON.stringify(subSubagentConversation) + '\n',
);
const trajectories = await chatRecordingService.getSubagentTrajectories();
expect(trajectories).toHaveProperty(subagentId);
expect(trajectories).toHaveProperty(subSubagentId);
expect(trajectories[subagentId].sessionId).toBe(subagentId);
expect(trajectories[subSubagentId].sessionId).toBe(subSubagentId);
});
it('should return empty object if no subagents are called', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: 'hello',
model: 'gemini-pro',
});
const trajectories = await chatRecordingService.getSubagentTrajectories();
expect(trajectories).toEqual({});
});
});
});
@@ -920,6 +920,74 @@ export class ChatRecordingService {
throw error;
}
}
/**
* Recursively collects all subagent trajectories associated with this session.
*/
async getSubagentTrajectories(): Promise<Record<string, ConversationRecord>> {
const allTrajectories: Record<string, ConversationRecord> = {};
await this.collectSubagentTrajectories(
this.sessionId,
this.getConversation(),
allTrajectories,
);
return allTrajectories;
}
private async collectSubagentTrajectories(
sessionId: string,
conversation: ConversationRecord | null,
allTrajectories: Record<string, ConversationRecord>,
) {
if (!conversation) return;
const agentIds = new Set<string>();
for (const message of conversation.messages) {
if (message.type === 'gemini' && message.toolCalls) {
for (const toolCall of message.toolCalls) {
if (toolCall.agentId && !allTrajectories[toolCall.agentId]) {
agentIds.add(toolCall.agentId);
}
}
}
}
if (agentIds.size === 0) return;
const tempDir = this.context.config.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const safeParentId = sanitizeFilenamePart(sessionId);
if (!safeParentId) return;
const loadPromises = Array.from(agentIds).map(async (agentId) => {
const subagentFilePath = path.join(
chatsDir,
safeParentId,
`${agentId}.jsonl`,
);
try {
const subagentConversation =
await loadConversationRecord(subagentFilePath);
if (subagentConversation) {
allTrajectories[agentId] = subagentConversation;
// Recursively collect for this subagent
await this.collectSubagentTrajectories(
agentId,
subagentConversation,
allTrajectories,
);
}
} catch (err) {
debugLogger.warn(
`Failed to load subagent trajectory for ${agentId}:`,
err,
);
}
});
await Promise.all(loadPromises);
}
}
async function parseLegacyRecordFallback(