Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,6 @@ coverage/

locales/

*.tgz
*.tgz

.claude
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

## [2.1.3] - 2025-09-13

### Added

- Enhanced LangChain adapter with comprehensive ToolMessage support
- Metadata preservation system for maintaining message properties during compression
- Content preservation for complex message types (tool calls, function calls, etc.)
- Extended SlimContextMessage interface with optional metadata field
- **Smart compression timing**: Added `shouldAllowCompression` function to prevent compression during active tool use cycles
- Comprehensive test coverage for tool message handling and metadata preservation
- Enhanced test coverage for compression timing and tool interaction patterns

### Changed

- Improved LangChain message conversion with robust metadata handling
- Enhanced content extraction with fallback preservation for complex content types
- Better roundtrip fidelity for LangChain BaseMessage conversions
- **TrimCompressor and SummarizeCompressor now only compress when the last message is from a user/human** to avoid disrupting tool use workflows

### Fixed

- Tool message conversion now properly preserves tool_call_id and other tool-specific fields
- Complex message content (arrays, objects) is now correctly preserved during compression
- Message metadata fields are properly maintained throughout compression workflows
- **Compression timing**: Prevents inappropriate compression during tool interactions that could break multi-turn tool workflows

## [2.1.2] - 2025-09-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "slimcontext",
"version": "2.1.2",
"version": "2.1.3",
"description": "Lightweight, model-agnostic chat history compression (trim + summarize) for AI assistants.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
88 changes: 83 additions & 5 deletions src/adapters/langchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import {
HumanMessage,
isAIMessage,
SystemMessage,
ToolMessage,
isHumanMessage,
isSystemMessage,
isToolMessage,
AIMessageFields,
SystemMessageFields,
HumanMessageFields,
} from '@langchain/core/messages';

import {
Expand Down Expand Up @@ -79,23 +83,97 @@ export function getLangChainMessageType(msg: BaseMessage): string {
/** Convert a LangChain BaseMessage to a SlimContextMessage used by compression. */
export function baseToSlim(msg: BaseMessage): SlimContextMessage {
const type = getLangChainMessageType(msg);
return {

// Collect all metadata fields to preserve during conversion
const metadata: Record<string, unknown> = {};

// Preserve common BaseMessage fields
if (msg.id !== undefined) metadata.id = msg.id;
if (msg.name !== undefined) metadata.name = msg.name;
if (msg.additional_kwargs && Object.keys(msg.additional_kwargs).length > 0) {
metadata.additional_kwargs = msg.additional_kwargs;
}
if (msg.response_metadata && Object.keys(msg.response_metadata).length > 0) {
metadata.response_metadata = msg.response_metadata;
}

// Preserve tool-specific fields
if (isToolMessage(msg)) {
metadata.tool_call_id = msg.tool_call_id;
if (msg.status !== undefined) metadata.status = msg.status;
if (msg.artifact !== undefined) metadata.artifact = msg.artifact;
if (msg.metadata !== undefined) metadata.langchain_metadata = msg.metadata;
}

const extractedContent = extractContent(msg.content as unknown);

// If extractContent returns empty but original content exists, preserve original content
// Note: msg.content can be undefined, empty string, or have content that extractContent can't process
if (!extractedContent && msg.content !== undefined && msg.content !== null) {
metadata.original_content = msg.content;
}

const result: SlimContextMessage = {
role: roleFromMessageType(type),
content: extractContent(msg.content as unknown),
content: extractedContent,
};

// Only add metadata if there are fields to preserve
if (Object.keys(metadata).length > 0) {
result.metadata = metadata;
}

return result;
}

/** Map SlimContextMessage back to a LangChain BaseMessage class. */
export function slimToLangChain(msg: SlimContextMessage): BaseMessage {
// Use original content if preserved, otherwise use processed content
const content = msg.metadata?.original_content || msg.content;

// Prepare fields with preserved metadata
const fields: Record<string, unknown> = {
content,
};

// Restore preserved metadata fields
if (msg.metadata) {
if (msg.metadata.id !== undefined) fields.id = msg.metadata.id;
if (msg.metadata.name !== undefined) fields.name = msg.metadata.name;
if (msg.metadata.additional_kwargs !== undefined) {
fields.additional_kwargs = msg.metadata.additional_kwargs;
}
if (msg.metadata.response_metadata !== undefined) {
fields.response_metadata = msg.metadata.response_metadata;
}
}

switch (msg.role) {
case 'assistant':
return new AIMessage(msg.content);
return new AIMessage(fields as AIMessageFields);
case 'system':
return new SystemMessage(msg.content);
return new SystemMessage(fields as SystemMessageFields);
case 'tool': {
// For tool messages, we need the tool_call_id and other tool-specific fields
const toolFields = { ...fields };
if (msg.metadata?.tool_call_id) {
toolFields.tool_call_id = msg.metadata.tool_call_id;
} else {
// Fallback to empty string if no tool_call_id preserved (shouldn't happen with new logic)
toolFields.tool_call_id = '';
}
if (msg.metadata?.status !== undefined) toolFields.status = msg.metadata.status;
if (msg.metadata?.artifact !== undefined) toolFields.artifact = msg.metadata.artifact;
if (msg.metadata?.langchain_metadata !== undefined) {
toolFields.metadata = msg.metadata.langchain_metadata;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new ToolMessage(toolFields as any);
}
case 'user':
case 'human':
default:
return new HumanMessage(msg.content);
return new HumanMessage(fields as HumanMessageFields);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
export interface SlimContextMessage {
role: 'system' | 'user' | 'assistant' | 'tool' | 'human';
content: string;
/** Optional metadata to preserve additional message fields during conversion */
metadata?: Record<string, unknown>;
}

export interface SlimContextModelResponse {
Expand Down
10 changes: 10 additions & 0 deletions src/strategies/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,13 @@ export function estimateTotalTokens(
for (const m of messages) total += estimateTokens(m);
return total;
}

/**
* Check if compression should be allowed based on the last message type.
* Only compress when the last message is from a user to avoid disrupting tool use cycles.
*/
export function shouldAllowCompression(messages: SlimContextMessage[]): boolean {
if (messages.length === 0) return false;
const lastMessage = messages[messages.length - 1];
return lastMessage.role === 'user' || lastMessage.role === 'human';
}
7 changes: 6 additions & 1 deletion src/strategies/summarize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
SlimContextMessage,
type TokenBudgetConfig,
} from '../interfaces';
import { normalizeBudgetConfig, computeThresholdTokens } from './common';
import { normalizeBudgetConfig, computeThresholdTokens, shouldAllowCompression } from './common';

const DEFAULT_SUMMARY_PROMPT = `
You are a conversation summarizer.
Expand Down Expand Up @@ -73,6 +73,11 @@ export class SummarizeCompressor implements SlimContextCompressor {
* Compress the conversation history by summarizing the middle portion.
*/
async compress(messages: SlimContextMessage[]): Promise<SlimContextMessage[]> {
// Only compress when the last message is from a user to avoid disrupting tool use cycles
if (!shouldAllowCompression(messages)) {
return messages;
}

const thresholdTokens = computeThresholdTokens(
this.cfg.maxModelTokens,
this.cfg.thresholdPercent,
Expand Down
7 changes: 6 additions & 1 deletion src/strategies/trim.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SlimContextCompressor, SlimContextMessage, type TokenBudgetConfig } from '../interfaces';
import { normalizeBudgetConfig, computeThresholdTokens } from './common';
import { normalizeBudgetConfig, computeThresholdTokens, shouldAllowCompression } from './common';

/**
* Trim configuration options for the TrimCompressor using token thresholding.
Expand All @@ -19,6 +19,11 @@ export class TrimCompressor implements SlimContextCompressor {
}

async compress(messages: SlimContextMessage[]): Promise<SlimContextMessage[]> {
// Only compress when the last message is from a user to avoid disrupting tool use cycles
if (!shouldAllowCompression(messages)) {
return messages;
}

const thresholdTokens = computeThresholdTokens(
this.cfg.maxModelTokens,
this.cfg.thresholdPercent,
Expand Down
Loading