@compilr-dev/agents 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -156,39 +156,77 @@ export function normalizeMessages(messages) {
156
156
  /**
157
157
  * Repair tool use/result pairing issues in a message array.
158
158
  *
159
- * This function removes orphaned tool_result blocks (those without a matching tool_use)
160
- * which can cause API errors like "function_response.name: Name cannot be empty" in Gemini.
159
+ * This function handles BOTH types of orphans with POSITIONAL requirements:
160
+ * 1. Orphaned tool_result blocks (those without a matching tool_use BEFORE them)
161
+ * - Causes: "function_response.name: Name cannot be empty" in Gemini
162
+ * 2. Orphaned tool_use blocks (those without tool_result in IMMEDIATELY NEXT message)
163
+ * - Causes: "tool_use ids were found without tool_result blocks" in Claude
161
164
  *
162
- * This is particularly useful after context compaction/summarization, which may remove
163
- * tool_use messages while preserving tool_result messages in the "recent" window.
165
+ * CRITICAL: Claude API requires tool_result to be in the IMMEDIATELY NEXT message
166
+ * after the assistant message containing tool_use. This function validates that
167
+ * positional requirement, not just existence anywhere in the array.
168
+ *
169
+ * This is particularly useful after context compaction/summarization, which may
170
+ * remove messages and break tool_use/tool_result pairing.
164
171
  *
165
172
  * @param messages - Array of messages to repair
166
- * @returns New array with orphaned tool_results removed
173
+ * @returns New array with orphaned tool_use and tool_result blocks removed
167
174
  */
168
175
  export function repairToolPairing(messages) {
169
- // First, collect all tool_use IDs
170
- const toolUseIds = new Set();
171
- for (const message of messages) {
176
+ // First pass: collect all tool_use IDs that appear BEFORE their tool_result
177
+ // Also track which tool_use IDs have their result in the IMMEDIATELY NEXT message
178
+ const validToolUseIds = new Set();
179
+ const allToolUseIds = new Set();
180
+ for (let i = 0; i < messages.length; i++) {
181
+ const message = messages[i];
172
182
  if (typeof message.content === 'string')
173
183
  continue;
174
- for (const block of message.content) {
175
- if (block.type === 'tool_use') {
176
- toolUseIds.add(block.id);
184
+ // Check for tool_use blocks in assistant messages
185
+ if (message.role === 'assistant') {
186
+ const toolUseIdsInThisMessage = [];
187
+ for (const block of message.content) {
188
+ if (block.type === 'tool_use') {
189
+ toolUseIdsInThisMessage.push(block.id);
190
+ allToolUseIds.add(block.id);
191
+ }
192
+ }
193
+ // Check if the NEXT message contains tool_result for ALL these tool_use IDs
194
+ if (toolUseIdsInThisMessage.length > 0) {
195
+ // Next message may not exist if we're at the end of the array
196
+ const nextMessage = messages[i + 1];
197
+ if (nextMessage && typeof nextMessage.content !== 'string') {
198
+ const toolResultIdsInNext = new Set();
199
+ for (const block of nextMessage.content) {
200
+ if (block.type === 'tool_result') {
201
+ toolResultIdsInNext.add(block.toolUseId);
202
+ }
203
+ }
204
+ // Mark tool_use IDs as valid only if their result is in the next message
205
+ for (const id of toolUseIdsInThisMessage) {
206
+ if (toolResultIdsInNext.has(id)) {
207
+ validToolUseIds.add(id);
208
+ }
209
+ }
210
+ }
177
211
  }
178
212
  }
179
213
  }
180
- // Now filter out orphaned tool_results
214
+ // Second pass: filter out orphaned blocks
181
215
  const repairedMessages = [];
182
216
  for (const message of messages) {
183
217
  if (typeof message.content === 'string') {
184
218
  repairedMessages.push(message);
185
219
  continue;
186
220
  }
187
- // Filter content blocks to remove orphaned tool_results
221
+ // Filter content blocks to remove orphaned tool_use and tool_result
188
222
  const filteredBlocks = message.content.filter((block) => {
189
223
  if (block.type === 'tool_result') {
190
- // Only keep if there's a matching tool_use
191
- return toolUseIds.has(block.toolUseId);
224
+ // Only keep if there's a valid matching tool_use (that also has its result properly placed)
225
+ return validToolUseIds.has(block.toolUseId);
226
+ }
227
+ if (block.type === 'tool_use') {
228
+ // Only keep if this tool_use has its result in the immediately next message
229
+ return validToolUseIds.has(block.id);
192
230
  }
193
231
  return true;
194
232
  });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * GeminiNativeProvider - Native Google Gen AI SDK implementation
3
+ *
4
+ * Uses the native @google/genai SDK instead of OpenAI-compatible endpoint.
5
+ * This enables proper support for Gemini 3 models with thought signatures.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const provider = new GeminiNativeProvider({
10
+ * apiKey: process.env.GOOGLE_AI_API_KEY,
11
+ * });
12
+ *
13
+ * const agent = new Agent({ provider });
14
+ * ```
15
+ */
16
+ import type { LLMProvider, Message, ChatOptions, StreamChunk } from './types.js';
17
+ /**
18
+ * Configuration for GeminiNativeProvider
19
+ */
20
+ export interface GeminiNativeProviderConfig {
21
+ /**
22
+ * Google AI API key
23
+ */
24
+ apiKey: string;
25
+ /**
26
+ * Default model to use
27
+ * @default 'gemini-2.5-flash'
28
+ */
29
+ model?: string;
30
+ /**
31
+ * Default max tokens
32
+ * @default 4096
33
+ */
34
+ maxTokens?: number;
35
+ }
36
+ /**
37
+ * GeminiNativeProvider implements LLMProvider using the native Google Gen AI SDK
38
+ *
39
+ * Benefits over OpenAI-compatible endpoint:
40
+ * - Automatic thought signature handling for Gemini 3
41
+ * - Native streaming support
42
+ * - Full access to Gemini-specific features
43
+ */
44
+ export declare class GeminiNativeProvider implements LLMProvider {
45
+ readonly name = "gemini";
46
+ private readonly client;
47
+ private readonly defaultModel;
48
+ private readonly defaultMaxTokens;
49
+ constructor(config: GeminiNativeProviderConfig);
50
+ /**
51
+ * Send messages and stream the response
52
+ */
53
+ chat(messages: Message[], options?: ChatOptions): AsyncIterable<StreamChunk>;
54
+ /**
55
+ * Count tokens in messages (approximation)
56
+ */
57
+ countTokens(messages: Message[]): Promise<number>;
58
+ /**
59
+ * Convert our Message format to Google's format
60
+ */
61
+ private convertMessages;
62
+ /**
63
+ * Convert content to Google's Part format
64
+ */
65
+ private convertContent;
66
+ /**
67
+ * Extract tool name from tool_use block that matches the given toolUseId
68
+ */
69
+ private extractToolName;
70
+ /**
71
+ * Convert our ToolDefinition to Google's FunctionDeclaration format
72
+ */
73
+ private convertTools;
74
+ /**
75
+ * Process a streaming chunk into StreamChunks
76
+ */
77
+ private processChunk;
78
+ /**
79
+ * Map errors to ProviderError
80
+ */
81
+ private mapError;
82
+ }
83
+ /**
84
+ * Create a GeminiNativeProvider with API key from environment
85
+ */
86
+ export declare function createGeminiNativeProvider(config?: Partial<GeminiNativeProviderConfig>): GeminiNativeProvider;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * GeminiNativeProvider - Native Google Gen AI SDK implementation
3
+ *
4
+ * Uses the native @google/genai SDK instead of OpenAI-compatible endpoint.
5
+ * This enables proper support for Gemini 3 models with thought signatures.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const provider = new GeminiNativeProvider({
10
+ * apiKey: process.env.GOOGLE_AI_API_KEY,
11
+ * });
12
+ *
13
+ * const agent = new Agent({ provider });
14
+ * ```
15
+ */
16
+ import { GoogleGenAI } from '@google/genai';
17
+ import { ProviderError } from '../errors.js';
18
+ /**
19
+ * Default model for Gemini API
20
+ */
21
+ const DEFAULT_MODEL = 'gemini-2.5-flash';
22
+ /**
23
+ * Default max tokens
24
+ */
25
+ const DEFAULT_MAX_TOKENS = 4096;
26
+ /**
27
+ * GeminiNativeProvider implements LLMProvider using the native Google Gen AI SDK
28
+ *
29
+ * Benefits over OpenAI-compatible endpoint:
30
+ * - Automatic thought signature handling for Gemini 3
31
+ * - Native streaming support
32
+ * - Full access to Gemini-specific features
33
+ */
34
+ export class GeminiNativeProvider {
35
+ name = 'gemini';
36
+ client;
37
+ defaultModel;
38
+ defaultMaxTokens;
39
+ constructor(config) {
40
+ this.client = new GoogleGenAI({ apiKey: config.apiKey });
41
+ this.defaultModel = config.model ?? DEFAULT_MODEL;
42
+ this.defaultMaxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
43
+ }
44
+ /**
45
+ * Send messages and stream the response
46
+ */
47
+ async *chat(messages, options) {
48
+ const { systemInstruction, contents } = this.convertMessages(messages);
49
+ const tools = this.convertTools(options?.tools);
50
+ const model = options?.model ?? this.defaultModel;
51
+ try {
52
+ // Build config
53
+ const config = {
54
+ systemInstruction,
55
+ maxOutputTokens: options?.maxTokens ?? this.defaultMaxTokens,
56
+ temperature: options?.temperature,
57
+ stopSequences: options?.stopSequences,
58
+ };
59
+ // Add tools if any
60
+ if (tools.length > 0) {
61
+ config.tools = [{ functionDeclarations: tools }];
62
+ }
63
+ // Request streaming response
64
+ const streamResponse = await this.client.models.generateContentStream({
65
+ model,
66
+ contents,
67
+ config,
68
+ });
69
+ // Track state for tool calls
70
+ let currentToolId = '';
71
+ let currentToolName = '';
72
+ let toolInputJson = '';
73
+ let inThinkingBlock = false;
74
+ // Process stream chunks
75
+ for await (const chunk of streamResponse) {
76
+ const streamChunks = this.processChunk(chunk, currentToolId, currentToolName, toolInputJson, inThinkingBlock);
77
+ for (const streamChunk of streamChunks) {
78
+ // Update tracking state
79
+ if (streamChunk.type === 'tool_use_start' && streamChunk.toolUse) {
80
+ currentToolId = streamChunk.toolUse.id;
81
+ currentToolName = streamChunk.toolUse.name;
82
+ toolInputJson = '';
83
+ }
84
+ else if (streamChunk.type === 'tool_use_delta' && streamChunk.text) {
85
+ toolInputJson += streamChunk.text;
86
+ }
87
+ else if (streamChunk.type === 'tool_use_end') {
88
+ currentToolId = '';
89
+ currentToolName = '';
90
+ toolInputJson = '';
91
+ }
92
+ else if (streamChunk.type === 'thinking_start') {
93
+ inThinkingBlock = true;
94
+ }
95
+ else if (streamChunk.type === 'thinking_end') {
96
+ inThinkingBlock = false;
97
+ }
98
+ yield streamChunk;
99
+ }
100
+ // Check for usage in the final chunk
101
+ if (chunk.usageMetadata) {
102
+ yield {
103
+ type: 'done',
104
+ model,
105
+ usage: {
106
+ inputTokens: chunk.usageMetadata.promptTokenCount ?? 0,
107
+ outputTokens: chunk.usageMetadata.candidatesTokenCount ?? 0,
108
+ },
109
+ };
110
+ }
111
+ }
112
+ }
113
+ catch (error) {
114
+ throw this.mapError(error);
115
+ }
116
+ }
117
+ /**
118
+ * Count tokens in messages (approximation)
119
+ */
120
+ countTokens(messages) {
121
+ // Approximation: ~4 characters per token
122
+ let charCount = 0;
123
+ for (const msg of messages) {
124
+ if (typeof msg.content === 'string') {
125
+ charCount += msg.content.length;
126
+ }
127
+ else {
128
+ for (const block of msg.content) {
129
+ switch (block.type) {
130
+ case 'text':
131
+ charCount += block.text.length;
132
+ break;
133
+ case 'tool_use':
134
+ charCount += JSON.stringify(block.input).length;
135
+ break;
136
+ case 'tool_result':
137
+ charCount += block.content.length;
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return Promise.resolve(Math.ceil(charCount / 4));
144
+ }
145
+ /**
146
+ * Convert our Message format to Google's format
147
+ */
148
+ convertMessages(messages) {
149
+ let systemInstruction;
150
+ const contents = [];
151
+ for (const msg of messages) {
152
+ if (msg.role === 'system') {
153
+ // Google uses systemInstruction in config, not a message
154
+ systemInstruction = typeof msg.content === 'string' ? msg.content : '';
155
+ }
156
+ else {
157
+ // user → user, assistant → model
158
+ const role = msg.role === 'assistant' ? 'model' : 'user';
159
+ const parts = this.convertContent(msg.content);
160
+ contents.push({ role, parts });
161
+ }
162
+ }
163
+ return { systemInstruction, contents };
164
+ }
165
+ /**
166
+ * Convert content to Google's Part format
167
+ */
168
+ convertContent(content) {
169
+ if (typeof content === 'string') {
170
+ return [{ text: content }];
171
+ }
172
+ return content.map((block) => {
173
+ switch (block.type) {
174
+ case 'text':
175
+ return { text: block.text };
176
+ case 'tool_use':
177
+ // Convert to functionCall with thought signature if present
178
+ // Gemini 3 requires thought signatures on function calls
179
+ return {
180
+ functionCall: {
181
+ id: block.id,
182
+ name: block.name,
183
+ args: block.input,
184
+ },
185
+ // Include thought signature if present (required for Gemini 3)
186
+ ...(block.signature ? { thoughtSignature: block.signature } : {}),
187
+ };
188
+ case 'tool_result':
189
+ // Convert to functionResponse
190
+ return {
191
+ functionResponse: {
192
+ id: block.toolUseId,
193
+ name: this.extractToolName(content, block.toolUseId),
194
+ response: { result: block.content },
195
+ },
196
+ };
197
+ case 'thinking':
198
+ // Pass through thinking with signature if present
199
+ return {
200
+ text: block.thinking,
201
+ thought: true,
202
+ thoughtSignature: block.signature,
203
+ };
204
+ default: {
205
+ // Exhaustive check
206
+ const _exhaustive = block;
207
+ throw new Error(`Unknown content block type: ${_exhaustive.type}`);
208
+ }
209
+ }
210
+ });
211
+ }
212
+ /**
213
+ * Extract tool name from tool_use block that matches the given toolUseId
214
+ */
215
+ extractToolName(content, toolUseId) {
216
+ for (const block of content) {
217
+ if (block.type === 'tool_use' && block.id === toolUseId) {
218
+ return block.name;
219
+ }
220
+ }
221
+ // Fallback if not found in same message - this shouldn't happen normally
222
+ return 'unknown_function';
223
+ }
224
+ /**
225
+ * Convert our ToolDefinition to Google's FunctionDeclaration format
226
+ */
227
+ convertTools(tools) {
228
+ if (!tools || tools.length === 0) {
229
+ return [];
230
+ }
231
+ return tools.map((tool) => ({
232
+ name: tool.name,
233
+ description: tool.description,
234
+ // Use parametersJsonSchema for raw JSON schema (more flexible than typed Schema)
235
+ parametersJsonSchema: {
236
+ type: 'object',
237
+ properties: tool.inputSchema.properties,
238
+ required: tool.inputSchema.required,
239
+ },
240
+ }));
241
+ }
242
+ /**
243
+ * Process a streaming chunk into StreamChunks
244
+ */
245
+ processChunk(chunk, _currentToolId, _currentToolName, _toolInputJson, inThinkingBlock) {
246
+ const chunks = [];
247
+ // Get the first candidate's content
248
+ const candidate = chunk.candidates?.[0];
249
+ if (!candidate?.content?.parts) {
250
+ return chunks;
251
+ }
252
+ for (const part of candidate.content.parts) {
253
+ // Text content
254
+ if (part.text !== undefined && !part.thought) {
255
+ chunks.push({
256
+ type: 'text',
257
+ text: part.text,
258
+ });
259
+ }
260
+ // Thinking content (Gemini 3)
261
+ if (part.thought && part.text !== undefined) {
262
+ if (!inThinkingBlock) {
263
+ chunks.push({ type: 'thinking_start' });
264
+ }
265
+ chunks.push({
266
+ type: 'thinking_delta',
267
+ text: part.text,
268
+ thinking: {
269
+ thinking: part.text,
270
+ signature: part.thoughtSignature,
271
+ },
272
+ });
273
+ }
274
+ // Function call
275
+ if (part.functionCall) {
276
+ const funcCall = part.functionCall;
277
+ const toolId = funcCall.id ?? `tool_${String(Date.now())}_${Math.random().toString(36).slice(2, 9)}`;
278
+ // Capture thought signature if present (Gemini 3 attaches it to function calls)
279
+ // Note: thoughtSignature is not in the SDK types yet but exists on the response
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
281
+ const signature = part.thoughtSignature;
282
+ chunks.push({
283
+ type: 'tool_use_start',
284
+ toolUse: {
285
+ id: toolId,
286
+ name: funcCall.name ?? 'unknown',
287
+ signature, // Pass through the thought signature
288
+ },
289
+ });
290
+ // Emit the arguments as delta
291
+ if (funcCall.args) {
292
+ chunks.push({
293
+ type: 'tool_use_delta',
294
+ text: JSON.stringify(funcCall.args),
295
+ });
296
+ }
297
+ chunks.push({ type: 'tool_use_end' });
298
+ }
299
+ }
300
+ // Check for finish reason to end thinking block
301
+ if (inThinkingBlock && candidate.finishReason) {
302
+ chunks.push({ type: 'thinking_end' });
303
+ }
304
+ return chunks;
305
+ }
306
+ /**
307
+ * Map errors to ProviderError
308
+ */
309
+ mapError(error) {
310
+ if (error instanceof Error) {
311
+ const message = error.message;
312
+ // Check for common error patterns
313
+ if (message.includes('API key')) {
314
+ return new ProviderError('Invalid Gemini API key. Check your GOOGLE_AI_API_KEY or GEMINI_API_KEY.', 'gemini', 401, error);
315
+ }
316
+ if (message.includes('rate limit') || message.includes('429')) {
317
+ return new ProviderError('Gemini rate limit exceeded. Please wait and try again.', 'gemini', 429, error);
318
+ }
319
+ if (message.includes('not found') || message.includes('404')) {
320
+ return new ProviderError(`Gemini model not found: ${message}`, 'gemini', 404, error);
321
+ }
322
+ return new ProviderError(message, 'gemini', undefined, error);
323
+ }
324
+ return new ProviderError(String(error), 'gemini');
325
+ }
326
+ }
327
+ /**
328
+ * Create a GeminiNativeProvider with API key from environment
329
+ */
330
+ export function createGeminiNativeProvider(config) {
331
+ const apiKey = config?.apiKey ?? process.env.GOOGLE_AI_API_KEY ?? process.env.GEMINI_API_KEY;
332
+ if (!apiKey) {
333
+ throw new ProviderError('Gemini API key not found. Set GOOGLE_AI_API_KEY or GEMINI_API_KEY environment variable or pass apiKey in config.', 'gemini');
334
+ }
335
+ return new GeminiNativeProvider({
336
+ ...config,
337
+ apiKey,
338
+ });
339
+ }
@@ -12,5 +12,10 @@ export { OpenAICompatibleProvider } from './openai-compatible.js';
12
12
  export type { OpenAICompatibleConfig, OpenAIMessage, OpenAIToolCall, OpenAITool, OpenAIStreamChunk, } from './openai-compatible.js';
13
13
  export { OpenAIProvider, createOpenAIProvider } from './openai.js';
14
14
  export type { OpenAIProviderConfig } from './openai.js';
15
- export { GeminiProvider, createGeminiProvider } from './gemini.js';
16
- export type { GeminiProviderConfig } from './gemini.js';
15
+ export { GeminiProvider as GeminiLegacyProvider, createGeminiProvider as createGeminiLegacyProvider, } from './gemini.js';
16
+ export type { GeminiProviderConfig as GeminiLegacyProviderConfig } from './gemini.js';
17
+ export { GeminiNativeProvider, createGeminiNativeProvider } from './gemini-native.js';
18
+ export type { GeminiNativeProviderConfig } from './gemini-native.js';
19
+ export { GeminiNativeProvider as GeminiProvider } from './gemini-native.js';
20
+ export { createGeminiNativeProvider as createGeminiProvider } from './gemini-native.js';
21
+ export type { GeminiNativeProviderConfig as GeminiProviderConfig } from './gemini-native.js';
@@ -11,5 +11,10 @@ export { OllamaProvider, createOllamaProvider } from './ollama.js';
11
11
  export { OpenAICompatibleProvider } from './openai-compatible.js';
12
12
  // OpenAI provider
13
13
  export { OpenAIProvider, createOpenAIProvider } from './openai.js';
14
- // Gemini provider
15
- export { GeminiProvider, createGeminiProvider } from './gemini.js';
14
+ // Gemini provider (legacy OpenAI-compatible endpoint)
15
+ export { GeminiProvider as GeminiLegacyProvider, createGeminiProvider as createGeminiLegacyProvider, } from './gemini.js';
16
+ // Gemini native provider (recommended - supports Gemini 3 thought signatures)
17
+ export { GeminiNativeProvider, createGeminiNativeProvider } from './gemini-native.js';
18
+ // Re-export native as default Gemini provider
19
+ export { GeminiNativeProvider as GeminiProvider } from './gemini-native.js';
20
+ export { createGeminiNativeProvider as createGeminiProvider } from './gemini-native.js';
@@ -24,6 +24,12 @@ export interface ToolUseBlock {
24
24
  id: string;
25
25
  name: string;
26
26
  input: Record<string, unknown>;
27
+ /**
28
+ * Thought signature for Gemini 3 function calls.
29
+ * Required for Gemini 3 to maintain reasoning context.
30
+ * @see https://ai.google.dev/gemini-api/docs/thought-signatures
31
+ */
32
+ signature?: string;
27
33
  }
28
34
  /**
29
35
  * Tool result content block (result of a tool call)
@@ -75,6 +81,11 @@ export interface StreamChunk {
75
81
  id: string;
76
82
  name: string;
77
83
  input?: Record<string, unknown>;
84
+ /**
85
+ * Thought signature for Gemini 3 function calls.
86
+ * Only present on first function call in each step.
87
+ */
88
+ signature?: string;
78
89
  };
79
90
  /**
80
91
  * Thinking block data (for thinking_start/thinking_end)
@@ -24,6 +24,7 @@ export declare function createAgentState(options: {
24
24
  messages: Message[];
25
25
  todos: TodoItem[];
26
26
  currentIteration: number;
27
+ turnCount: number;
27
28
  totalTokensUsed: number;
28
29
  createdAt?: string;
29
30
  }): AgentState;
@@ -22,6 +22,7 @@ export function createEmptyState(sessionId, systemPrompt) {
22
22
  systemPrompt,
23
23
  todos: [],
24
24
  currentIteration: 0,
25
+ turnCount: 0,
25
26
  totalTokensUsed: 0,
26
27
  createdAt: now,
27
28
  updatedAt: now,
@@ -40,6 +41,7 @@ export function createAgentState(options) {
40
41
  model: options.model,
41
42
  todos: serializeTodos(options.todos),
42
43
  currentIteration: options.currentIteration,
44
+ turnCount: options.turnCount,
43
45
  totalTokensUsed: options.totalTokensUsed,
44
46
  createdAt: options.createdAt || now,
45
47
  updatedAt: now,
@@ -34,7 +34,11 @@ export class JsonSerializer {
34
34
  throw StateError.deserialization('Invalid JSON format', error instanceof Error ? error : undefined);
35
35
  }
36
36
  this.validateParsed(parsed);
37
- return parsed;
37
+ // Ensure turnCount has a default value for backward compatibility
38
+ // with saved states from before turnCount was added (pre-v2 states)
39
+ const partialState = parsed;
40
+ const turnCount = partialState.turnCount ?? partialState.messages.filter((m) => m.role === 'user').length;
41
+ return { ...partialState, turnCount };
38
42
  }
39
43
  /**
40
44
  * Validate state before serialization (public interface method).
@@ -77,6 +81,11 @@ export class JsonSerializer {
77
81
  if (typeof state.version !== 'number') {
78
82
  throw StateError.invalidState('version must be a number');
79
83
  }
84
+ // turnCount is optional for backward compatibility
85
+ // (old saved states may not have it)
86
+ if (state.turnCount !== undefined && typeof state.turnCount !== 'number') {
87
+ throw StateError.invalidState('turnCount must be a number if present');
88
+ }
80
89
  // Version check - safe to cast since we validated it's a number
81
90
  const version = state.version;
82
91
  if (version > CURRENT_STATE_VERSION) {
@@ -119,7 +128,11 @@ export class CompactJsonSerializer {
119
128
  throw StateError.deserialization('Invalid JSON format', error instanceof Error ? error : undefined);
120
129
  }
121
130
  this.validateParsed(parsed);
122
- return parsed;
131
+ // Ensure turnCount has a default value for backward compatibility
132
+ // with saved states from before turnCount was added (pre-v2 states)
133
+ const partialState = parsed;
134
+ const turnCount = partialState.turnCount ?? partialState.messages.filter((m) => m.role === 'user').length;
135
+ return { ...partialState, turnCount };
123
136
  }
124
137
  /**
125
138
  * Validate state before serialization (public interface method).
@@ -159,6 +172,11 @@ export class CompactJsonSerializer {
159
172
  if (typeof state.version !== 'number') {
160
173
  throw StateError.invalidState('version must be a number');
161
174
  }
175
+ // turnCount is optional for backward compatibility
176
+ // (old saved states may not have it)
177
+ if (state.turnCount !== undefined && typeof state.turnCount !== 'number') {
178
+ throw StateError.invalidState('turnCount must be a number if present');
179
+ }
162
180
  // Version check - safe to cast since we validated it's a number
163
181
  const version = state.version;
164
182
  if (version > CURRENT_STATE_VERSION) {
@@ -35,6 +35,11 @@ export interface AgentState {
35
35
  * Current iteration count
36
36
  */
37
37
  currentIteration: number;
38
+ /**
39
+ * Number of conversation turns (user + assistant exchanges)
40
+ * Used by context manager to track recent vs old messages for compaction.
41
+ */
42
+ turnCount: number;
38
43
  /**
39
44
  * Total tokens used in the session
40
45
  */