Skip to content

Commit f4a9c69

Browse files
authored
Bugfix/compressor bug (#2833)
* added compressor settings * added compressor settings * updarted compressor and artifact fetching * updated bug branch for ci and comments * added changeset * fixed final pullfrog comments * updated base compressor to parallellize
1 parent 59d4a2e commit f4a9c69

6 files changed

Lines changed: 359 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@inkeep/agents-api": patch
3+
"@inkeep/agents-core": patch
4+
---
5+
6+
Fix key_findings persistence in compressor by using proper update instead of insert-only upsert

agents-api/src/domains/run/agents/tools/default-tools.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type Tool, type ToolSet, tool } from 'ai';
33
import { getLogger } from '../../../../logger';
44
import { formatOversizedRetrievalReason } from '../../artifacts/artifact-utils';
55
import { getModelAwareCompressionConfig } from '../../compression/BaseCompressor';
6+
import { SENTINEL_KEY } from '../../constants/artifact-syntax';
67
import { agentSessionManager } from '../../session/AgentSession';
78
import type { AgentRunContext } from '../agent-types';
89
import { wrapToolWithStreaming } from './tool-wrapper';
@@ -20,6 +21,21 @@ export function getArtifactTools(ctx: AgentRunContext): Tool<any, any> {
2021
execute: async ({ artifactId, toolCallId }) => {
2122
logger.info({ artifactId, toolCallId }, 'get_artifact_full executed');
2223

24+
const compressor = ctx.currentCompressor;
25+
if (compressor?.hasSummarizedArtifact(artifactId)) {
26+
const summarized = compressor.getSummarizedArtifact(artifactId);
27+
logger.info(
28+
{ artifactId, toolCallId },
29+
'Blocked retrieval of artifact already summarized in compression'
30+
);
31+
return {
32+
artifactId,
33+
status: 'already_summarized',
34+
key_findings: summarized?.key_findings ?? [],
35+
hint: `This artifact's key findings are already in your compressed context. Use them directly to answer. To pass this artifact to a tool, use { "${SENTINEL_KEY.ARTIFACT}": "${artifactId}", "${SENTINEL_KEY.TOOL}": "${summarized?.tool_call_id ?? toolCallId}" } sentinel instead of retrieving it.`,
36+
};
37+
}
38+
2339
const streamRequestId = ctx.streamRequestId ?? '';
2440
const artifactService = agentSessionManager.getArtifactService(streamRequestId);
2541

agents-api/src/domains/run/compression/BaseCompressor.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { randomUUID } from 'node:crypto';
2-
import type { ModelSettings } from '@inkeep/agents-core';
2+
import type { ModelSettings, Part } from '@inkeep/agents-core';
33
import {
44
estimateTokens as estimateTokensUtil,
55
GENERATION_TYPES,
66
getLedgerArtifacts,
77
SPAN_KEYS,
8+
updateLedgerArtifactParts,
89
} from '@inkeep/agents-core';
910
import { type Span, SpanStatusCode } from '@opentelemetry/api';
1011
import runDbClient from '../../../data/db/runDbClient';
@@ -539,6 +540,26 @@ export abstract class BaseCompressor {
539540
);
540541

541542
this.cumulativeSummary = summary;
543+
544+
if (summary.related_artifacts?.length) {
545+
try {
546+
const persistResult = await this.persistArtifactKeyFindings(summary.related_artifacts);
547+
logger.debug(
548+
{ ...persistResult, conversationId: this.conversationId },
549+
'Artifact key_findings persistence completed'
550+
);
551+
} catch (err) {
552+
logger.warn(
553+
{
554+
conversationId: this.conversationId,
555+
artifactIds: summary.related_artifacts.map((a) => a.id),
556+
err: err instanceof Error ? err.message : String(err),
557+
},
558+
'Failed to persist key_findings to artifacts'
559+
);
560+
}
561+
}
562+
542563
return summary;
543564
}
544565

@@ -615,6 +636,111 @@ export abstract class BaseCompressor {
615636
return this.cumulativeSummary;
616637
}
617638

639+
hasSummarizedArtifact(artifactId: string): boolean {
640+
return this.cumulativeSummary?.related_artifacts?.some((a) => a.id === artifactId) ?? false;
641+
}
642+
643+
getSummarizedArtifact(
644+
artifactId: string
645+
): { key_findings: string[]; tool_call_id: string } | null {
646+
const artifact = this.cumulativeSummary?.related_artifacts?.find((a) => a.id === artifactId);
647+
if (!artifact) return null;
648+
return {
649+
key_findings: artifact.key_findings,
650+
tool_call_id: artifact.tool_call_id,
651+
};
652+
}
653+
654+
protected async persistArtifactKeyFindings(
655+
relatedArtifacts: NonNullable<ConversationSummary['related_artifacts']>
656+
): Promise<{ persisted: number; skipped: number; failed: number }> {
657+
const result = { persisted: 0, skipped: 0, failed: 0 };
658+
const scopes = { tenantId: this.tenantId, projectId: this.projectId };
659+
660+
const artifactsWithFindings = relatedArtifacts.filter((a) => a.key_findings?.length);
661+
result.skipped += relatedArtifacts.length - artifactsWithFindings.length;
662+
663+
if (artifactsWithFindings.length === 0) return result;
664+
665+
const fetchResults = await Promise.allSettled(
666+
artifactsWithFindings.map((artifact) =>
667+
getLedgerArtifacts(runDbClient)({ scopes, artifactId: artifact.id }).then((existing) => ({
668+
artifact,
669+
existing,
670+
}))
671+
)
672+
);
673+
674+
const updates: Array<{ artifact: (typeof artifactsWithFindings)[number]; parts: Part[] }> = [];
675+
676+
for (const settled of fetchResults) {
677+
if (settled.status === 'rejected') {
678+
result.failed++;
679+
logger.warn(
680+
{ conversationId: this.conversationId, error: String(settled.reason) },
681+
'Failed to fetch artifact for key_findings persistence'
682+
);
683+
continue;
684+
}
685+
686+
const { artifact, existing } = settled.value;
687+
if (existing.length === 0) {
688+
logger.debug(
689+
{ artifactId: artifact.id },
690+
'Artifact not found in DB, skipping key_findings persistence'
691+
);
692+
result.skipped++;
693+
continue;
694+
}
695+
696+
const parts = structuredClone(existing[0].parts ?? []) as Part[];
697+
const firstPart = parts[0];
698+
if (!firstPart || firstPart.kind !== 'data' || !firstPart.data?.summary) {
699+
logger.debug(
700+
{ artifactId: artifact.id, hasParts: parts.length > 0 },
701+
'Artifact has no data part with summary structure, skipping key_findings persistence'
702+
);
703+
result.skipped++;
704+
continue;
705+
}
706+
707+
firstPart.data.summary = {
708+
...firstPart.data.summary,
709+
key_findings: artifact.key_findings,
710+
};
711+
712+
updates.push({ artifact, parts });
713+
}
714+
715+
const updateResults = await Promise.allSettled(
716+
updates.map(({ artifact, parts }) =>
717+
updateLedgerArtifactParts(runDbClient)({ scopes, artifactId: artifact.id, parts }).then(
718+
(updated) => ({ artifact, updated })
719+
)
720+
)
721+
);
722+
723+
for (const settled of updateResults) {
724+
if (settled.status === 'rejected') {
725+
result.failed++;
726+
logger.warn(
727+
{ conversationId: this.conversationId, error: String(settled.reason) },
728+
'Failed to persist key_findings to artifact'
729+
);
730+
continue;
731+
}
732+
const { artifact, updated } = settled.value;
733+
if (updated) {
734+
result.persisted++;
735+
} else {
736+
logger.warn({ artifactId: artifact.id }, 'updateLedgerArtifactParts matched no rows');
737+
result.failed++;
738+
}
739+
}
740+
741+
return result;
742+
}
743+
618744
cleanup(options: { resetSummary?: boolean; keepRecentToolCalls?: number } = {}): void {
619745
const { resetSummary = false, keepRecentToolCalls = 0 } = options;
620746
if (keepRecentToolCalls > 0) {

agents-api/src/domains/run/compression/__tests__/BaseCompressor.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,4 +691,140 @@ describe('BaseCompressor', () => {
691691
expect(compressor['processedToolCalls'].size).toBeGreaterThan(0);
692692
});
693693
});
694+
695+
describe('hasSummarizedArtifact', () => {
696+
it('should return false when cumulativeSummary is null', () => {
697+
// biome-ignore lint/complexity/useLiteralKeys: accessing private property for testing
698+
compressor['cumulativeSummary'] = null;
699+
expect(compressor.hasSummarizedArtifact('artifact-1')).toBe(false);
700+
});
701+
702+
it('should return false when related_artifacts is null', () => {
703+
// biome-ignore lint/complexity/useLiteralKeys: accessing private property for testing
704+
compressor['cumulativeSummary'] = {
705+
type: 'conversation_summary_v1',
706+
session_id: null,
707+
_fallback: null,
708+
high_level: 'summary',
709+
user_intent: 'test',
710+
decisions: [],
711+
open_questions: [],
712+
next_steps: { for_agent: [], for_user: [] },
713+
related_artifacts: null,
714+
};
715+
expect(compressor.hasSummarizedArtifact('artifact-1')).toBe(false);
716+
});
717+
718+
it('should return false when artifact is not in related_artifacts', () => {
719+
// biome-ignore lint/complexity/useLiteralKeys: accessing private property for testing
720+
compressor['cumulativeSummary'] = {
721+
type: 'conversation_summary_v1',
722+
session_id: null,
723+
_fallback: null,
724+
high_level: 'summary',
725+
user_intent: 'test',
726+
decisions: [],
727+
open_questions: [],
728+
next_steps: { for_agent: [], for_user: [] },
729+
related_artifacts: [
730+
{
731+
id: 'other-artifact',
732+
name: 'Other',
733+
tool_name: 'tool',
734+
tool_call_id: 'tc-1',
735+
content_type: 'text',
736+
key_findings: ['finding'],
737+
},
738+
],
739+
};
740+
expect(compressor.hasSummarizedArtifact('artifact-1')).toBe(false);
741+
});
742+
743+
it('should return true when artifact exists in related_artifacts', () => {
744+
// biome-ignore lint/complexity/useLiteralKeys: accessing private property for testing
745+
compressor['cumulativeSummary'] = {
746+
type: 'conversation_summary_v1',
747+
session_id: null,
748+
_fallback: null,
749+
high_level: 'summary',
750+
user_intent: 'test',
751+
decisions: [],
752+
open_questions: [],
753+
next_steps: { for_agent: [], for_user: [] },
754+
related_artifacts: [
755+
{
756+
id: 'artifact-1',
757+
name: 'Test Artifact',
758+
tool_name: 'tool',
759+
tool_call_id: 'tc-1',
760+
content_type: 'text',
761+
key_findings: ['finding'],
762+
},
763+
],
764+
};
765+
expect(compressor.hasSummarizedArtifact('artifact-1')).toBe(true);
766+
});
767+
});
768+
769+
describe('getSummarizedArtifact', () => {
770+
it('should return null when cumulativeSummary is null', () => {
771+
// biome-ignore lint/complexity/useLiteralKeys: accessing private property for testing
772+
compressor['cumulativeSummary'] = null;
773+
expect(compressor.getSummarizedArtifact('artifact-1')).toBeNull();
774+
});
775+
776+
it('should return null when artifact is not found', () => {
777+
// biome-ignore lint/complexity/useLiteralKeys: accessing private property for testing
778+
compressor['cumulativeSummary'] = {
779+
type: 'conversation_summary_v1',
780+
session_id: null,
781+
_fallback: null,
782+
high_level: 'summary',
783+
user_intent: 'test',
784+
decisions: [],
785+
open_questions: [],
786+
next_steps: { for_agent: [], for_user: [] },
787+
related_artifacts: [],
788+
};
789+
expect(compressor.getSummarizedArtifact('artifact-1')).toBeNull();
790+
});
791+
792+
it('should return key_findings and tool_call_id for matching artifact', () => {
793+
// biome-ignore lint/complexity/useLiteralKeys: accessing private property for testing
794+
compressor['cumulativeSummary'] = {
795+
type: 'conversation_summary_v1',
796+
session_id: null,
797+
_fallback: null,
798+
high_level: 'summary',
799+
user_intent: 'test',
800+
decisions: [],
801+
open_questions: [],
802+
next_steps: { for_agent: [], for_user: [] },
803+
related_artifacts: [
804+
{
805+
id: 'artifact-1',
806+
name: 'Test Artifact',
807+
tool_name: 'tool',
808+
tool_call_id: 'tc-42',
809+
content_type: 'text',
810+
key_findings: ['finding-a', 'finding-b'],
811+
},
812+
{
813+
id: 'artifact-2',
814+
name: 'Other Artifact',
815+
tool_name: 'tool',
816+
tool_call_id: 'tc-99',
817+
content_type: 'text',
818+
key_findings: ['other-finding'],
819+
},
820+
],
821+
};
822+
823+
const result = compressor.getSummarizedArtifact('artifact-1');
824+
expect(result).toEqual({
825+
key_findings: ['finding-a', 'finding-b'],
826+
tool_call_id: 'tc-42',
827+
});
828+
});
829+
});
694830
});

packages/agents-core/src/__tests__/data-access/ledgerArtifacts.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
deleteLedgerArtifactsByTask,
77
getLedgerArtifacts,
88
getLedgerArtifactsByContext,
9+
updateLedgerArtifactParts,
910
} from '../../data-access/runtime/ledgerArtifacts';
1011
import type { AgentsRunDatabaseClient } from '../../db/runtime/runtime-client';
1112
import { testRunDbClient } from '../setup';
@@ -639,4 +640,55 @@ describe('Ledger Artifacts Data Access', () => {
639640
expect(result).toBe(0);
640641
});
641642
});
643+
644+
describe('updateLedgerArtifactParts', () => {
645+
it('should return true when artifact is successfully updated', async () => {
646+
const mockReturning = vi.fn().mockResolvedValue([{ id: testArtifactId }]);
647+
const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning });
648+
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
649+
const mockUpdate = vi.fn().mockReturnValue({ set: mockSet });
650+
651+
const mockDb = {
652+
...db,
653+
update: mockUpdate,
654+
} as any;
655+
656+
const newParts = [{ kind: 'data' as const, data: { summary: { key_findings: ['a', 'b'] } } }];
657+
658+
const result = await updateLedgerArtifactParts(mockDb)({
659+
scopes: { tenantId: testTenantId, projectId: testProjectId },
660+
artifactId: testArtifactId,
661+
parts: newParts,
662+
});
663+
664+
expect(result).toBe(true);
665+
expect(mockUpdate).toHaveBeenCalled();
666+
expect(mockSet).toHaveBeenCalledWith(
667+
expect.objectContaining({
668+
parts: newParts,
669+
updatedAt: expect.any(String),
670+
})
671+
);
672+
});
673+
674+
it('should return false when no artifact matches', async () => {
675+
const mockReturning = vi.fn().mockResolvedValue([]);
676+
const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning });
677+
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
678+
const mockUpdate = vi.fn().mockReturnValue({ set: mockSet });
679+
680+
const mockDb = {
681+
...db,
682+
update: mockUpdate,
683+
} as any;
684+
685+
const result = await updateLedgerArtifactParts(mockDb)({
686+
scopes: { tenantId: testTenantId, projectId: testProjectId },
687+
artifactId: 'non-existent-artifact',
688+
parts: [{ kind: 'text' as const, text: 'test' }],
689+
});
690+
691+
expect(result).toBe(false);
692+
});
693+
});
642694
});

0 commit comments

Comments
 (0)