-
Notifications
You must be signed in to change notification settings - Fork 111
fix(run): return structured conversation history and preserve multi-artifact tool results #2743
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
8eea5bf
a96c949
4e1416a
37a910e
513ed69
393705c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { | |
| generateId, | ||
| getConversationHistory, | ||
| getLedgerArtifacts, | ||
| type MessageSelect, | ||
| type ResolvedRef, | ||
| } from '@inkeep/agents-core'; | ||
| import { trace } from '@opentelemetry/api'; | ||
|
|
@@ -145,7 +146,7 @@ export async function getScopedHistory({ | |
| conversationId: string; | ||
| filters?: ConversationScopeOptions; | ||
| options?: ConversationHistoryConfig; | ||
| }): Promise<any[]> { | ||
| }): Promise<MessageSelect[]> { | ||
| try { | ||
| // First, get ALL messages to find the latest compression summary | ||
| // IMPORTANT: Always include internal messages and disable truncation to ensure tool results are available | ||
|
|
@@ -172,7 +173,7 @@ export async function getScopedHistory({ | |
|
|
||
| const limit = options?.limit; | ||
|
|
||
| let messages: any[]; | ||
| let messages: MessageSelect[]; | ||
| if (latestCompressionSummary) { | ||
| // Get the summary + all messages after it | ||
| const summaryDate = new Date(latestCompressionSummary.createdAt); | ||
|
|
@@ -426,7 +427,7 @@ export async function getConversationHistoryWithCompression({ | |
| baseModel?: any; | ||
| streamRequestId?: string; | ||
| fullContextSize?: number; | ||
| }): Promise<string> { | ||
| }): Promise<MessageSelect[]> { | ||
| const historyOptions = options ?? createDefaultConversationHistoryConfig(); | ||
|
|
||
| // IMPORTANT: For conversation compression, we MUST include internal messages (tool results) | ||
|
|
@@ -458,7 +459,7 @@ export async function getConversationHistoryWithCompression({ | |
| } | ||
|
|
||
| if (!messagesToFormat.length) { | ||
| return ''; | ||
| return []; | ||
| } | ||
|
|
||
| // Replace tool-result content with compact artifact references BEFORE compression. | ||
|
|
@@ -475,38 +476,45 @@ export async function getConversationHistoryWithCompression({ | |
| scopes: { tenantId, projectId }, | ||
| toolCallIds, | ||
| }); | ||
| const artifactsByToolCallId = new Map( | ||
| artifacts.filter((a) => a.toolCallId).map((a) => [a.toolCallId as string, a]) | ||
| ); | ||
| const artifactsByToolCallId = new Map<string, Artifact[]>(); | ||
| for (const artifact of artifacts) { | ||
| if (!artifact.toolCallId) continue; | ||
| const existing = artifactsByToolCallId.get(artifact.toolCallId) || []; | ||
| existing.push(artifact); | ||
| artifactsByToolCallId.set(artifact.toolCallId, existing); | ||
| } | ||
| if (artifactsByToolCallId.size > 0) { | ||
| messagesToFormat = messagesToFormat.map((msg) => { | ||
| if (msg.messageType !== 'tool-result') return msg; | ||
| const tcId = msg.metadata?.a2a_metadata?.toolCallId; | ||
| const artifact = tcId ? artifactsByToolCallId.get(tcId) : undefined; | ||
| if (!artifact) return msg; | ||
| const relatedArtifacts = tcId ? artifactsByToolCallId.get(tcId) : undefined; | ||
| if (!relatedArtifacts || relatedArtifacts.length === 0) return msg; | ||
| const toolArgs = msg.metadata?.a2a_metadata?.toolArgs; | ||
| const rawArgs = toolArgs ? JSON.stringify(toolArgs) : undefined; | ||
| const argsStr = | ||
| rawArgs && rawArgs.length > 300 ? `${rawArgs.slice(0, 300)}...[truncated]` : rawArgs; | ||
| const dataPart = artifact.parts?.find( | ||
| (p): p is Extract<(typeof artifact.parts)[number], { kind: 'data' }> => | ||
| p.kind === 'data' | ||
| ); | ||
| const summaryValue = dataPart?.data?.summary; | ||
| const rawSummary = summaryValue ? JSON.stringify(summaryValue) : undefined; | ||
| const summaryDataStr = | ||
| rawSummary && rawSummary.length > 1000 | ||
| ? `${rawSummary.slice(0, 1000)}...[truncated]` | ||
| : rawSummary; | ||
| const refParts = [ | ||
| `Artifact: "${artifact.name ?? artifact.artifactId}" (id: ${artifact.artifactId})`, | ||
| ]; | ||
| if (argsStr) refParts.push(`args: ${argsStr}`); | ||
| if (artifact.description) refParts.push(`description: ${artifact.description}`); | ||
| if (summaryDataStr) refParts.push(`summary: ${summaryDataStr}`); | ||
| const refParts = relatedArtifacts.map((artifact) => { | ||
| const dataPart = artifact.parts?.find( | ||
| (p): p is Extract<(typeof artifact.parts)[number], { kind: 'data' }> => | ||
| p.kind === 'data' | ||
| ); | ||
| const summaryValue = dataPart?.data?.summary; | ||
| const rawSummary = summaryValue ? JSON.stringify(summaryValue) : undefined; | ||
| const summaryDataStr = | ||
| rawSummary && rawSummary.length > 1000 | ||
| ? `${rawSummary.slice(0, 1000)}...[truncated]` | ||
| : rawSummary; | ||
| const artifactParts = [ | ||
| `Artifact: "${artifact.name ?? artifact.artifactId}" (id: ${artifact.artifactId})`, | ||
| ]; | ||
| if (argsStr) artifactParts.push(`args: ${argsStr}`); | ||
|
||
| if (artifact.description) artifactParts.push(`description: ${artifact.description}`); | ||
| if (summaryDataStr) artifactParts.push(`summary: ${summaryDataStr}`); | ||
| return `[${artifactParts.join(' | ')}]`; | ||
| }); | ||
| return { | ||
| ...msg, | ||
| content: { text: `[${refParts.join(' | ')}]` }, | ||
| content: { text: refParts.join('\n') }, | ||
| }; | ||
| }); | ||
| } | ||
|
|
@@ -560,7 +568,7 @@ export async function getConversationHistoryWithCompression({ | |
| } | ||
| } | ||
|
|
||
| return formatMessagesAsConversationHistory(messagesToFormat); | ||
| return messagesToFormat; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -892,10 +900,12 @@ export function reconstructMessageText(msg: any): string { | |
|
|
||
| return parts | ||
| .map((part: any) => { | ||
| if (part.type === 'text') { | ||
| const partKind = part.kind ?? part.type; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💭 Consider: Defensive fallback for Issue: This fallback suggests there are two different part schemas in play: the canonical Why: Without clarity on the source of Fix: If both are legitimate variants (e.g., legacy data or external formats), add a brief inline comment: // Support both 'kind' (canonical schema) and 'type' (legacy/external format)
const partKind = part.kind ?? part.type;If |
||
|
|
||
| if (partKind === 'text') { | ||
| return part.text ?? ''; | ||
| } | ||
| if (part.type === 'data') { | ||
| if (partKind === 'data') { | ||
| try { | ||
| const data = typeof part.data === 'string' ? JSON.parse(part.data) : part.data; | ||
| if (data?.artifactId && data?.toolCallId) { | ||
|
|
@@ -910,9 +920,9 @@ export function reconstructMessageText(msg: any): string { | |
| .join(''); | ||
| } | ||
|
|
||
| function formatMessagesAsConversationHistory(messages: any[]): string { | ||
| export function formatMessagesAsConversationHistory(messages: MessageSelect[]): string { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR: Empty array produces non-empty wrapper (Ito test failure root cause) Issue: When Why: This was flagged by the Ito test suite: "First-turn requests can still inject an empty Fix: Return empty string for empty input: export function formatMessagesAsConversationHistory(messages: MessageSelect[]): string {
if (messages.length === 0) {
return '';
}
// ... rest of function
}Refs:
|
||
| const formattedHistory = messages | ||
| .map((msg: any) => { | ||
| .map((msg: MessageSelect) => { | ||
| let roleLabel: string; | ||
|
|
||
| if (msg.role === 'user') { | ||
|
|
@@ -937,8 +947,13 @@ function formatMessagesAsConversationHistory(messages: any[]): string { | |
| roleLabel = msg.role || 'system'; | ||
| } | ||
|
|
||
| return `${roleLabel}: """${reconstructMessageText(msg)}"""`; | ||
| const reconstructedMessage = reconstructMessageText(msg); | ||
| if (!reconstructedMessage) { | ||
| return null; | ||
| } | ||
| return `${roleLabel}: """${reconstructedMessage}"""`; | ||
| }) | ||
| .filter((line): line is string => line !== null) | ||
|
Comment on lines
+933
to
+939
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Comment on lines
+933
to
+939
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💭 Consider: Behavioral change - empty messages now filtered out Issue: The new implementation filters out messages with empty reconstructed text (returning Why: This could affect downstream consumers that rely on message boundaries (e.g., compression logic counting messages) or debugging traces. If intentional, it's worth documenting. Fix: If intentional, consider adding a test case validating the filtering behavior and noting this in the PR description as a deliberate improvement.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @tim-inkeep does this seem reasonable? It's now filtering out empty messages, whereas before it would sometimes have messages like
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think this is fine |
||
| .join('\n'); | ||
|
|
||
| return `<conversation_history>\n${formattedHistory}\n</conversation_history>\n`; | ||
|
Comment on lines
902
to
946
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The newly exported
Since |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test verifies both artifact IDs are present but doesn't assert the
\njoin between them. If the join separator were accidentally changed to empty string or space, this test would still pass. Consider: