diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index 7cc1aa53..ec50fcbb 100644 --- a/src/ai-providers/agent-sdk/agent-sdk-provider.ts +++ b/src/ai-providers/agent-sdk/agent-sdk-provider.ts @@ -10,15 +10,17 @@ import { resolve } from 'node:path' import type { Tool } from 'ai' -import type { ProviderResult, ProviderEvent, AIProvider, GenerateInput, GenerateOpts } from '../types.js' +import type { ProviderResult, ProviderEvent, AIProvider, GenerateOpts } from '../types.js' +import type { SessionEntry } from '../../core/session.js' import type { AgentSdkConfig, AgentSdkOverride } from './query.js' +import { toTextHistory } from '../../core/session.js' +import { buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../utils.js' import { readAgentConfig } from '../../core/config.js' import { createChannel } from '../../core/async-channel.js' import { askAgentSdk } from './query.js' import { buildAgentSdkMcpServer } from './tool-bridge.js' export class AgentSdkProvider implements AIProvider { - readonly inputKind = 'text' as const readonly providerTag = 'agent-sdk' as const constructor( @@ -49,8 +51,10 @@ export class AgentSdkProvider implements AIProvider { return { text: result.text, media: [] } } - async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator { - if (input.kind !== 'text') throw new Error('AgentSdkProvider expects text input') + async *generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncGenerator { + const maxHistory = opts?.maxHistoryEntries ?? DEFAULT_MAX_HISTORY + const textHistory = toTextHistory(entries).slice(-maxHistory) + const fullPrompt = buildChatHistoryPrompt(prompt, textHistory, opts?.historyPreamble) const config = await this.resolveConfig() const agentSdkConfig: AgentSdkConfig = { @@ -58,7 +62,7 @@ export class AgentSdkProvider implements AIProvider { ...(opts?.disabledTools?.length ? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] } : {}), - systemPrompt: input.systemPrompt ?? this.systemPrompt, + systemPrompt: opts?.systemPrompt ?? this.systemPrompt, } const override: AgentSdkOverride | undefined = opts?.agentSdk @@ -67,7 +71,7 @@ export class AgentSdkProvider implements AIProvider { const channel = createChannel() const resultPromise = askAgentSdk( - input.prompt, + fullPrompt, { ...agentSdkConfig, onToolUse: ({ id, name, input: toolInput }) => { diff --git a/src/ai-providers/claude-code/claude-code-provider.ts b/src/ai-providers/claude-code/claude-code-provider.ts index d53baf46..dd3a1f01 100644 --- a/src/ai-providers/claude-code/claude-code-provider.ts +++ b/src/ai-providers/claude-code/claude-code-provider.ts @@ -9,14 +9,16 @@ */ import { resolve } from 'node:path' -import type { ProviderResult, ProviderEvent, AIProvider, GenerateInput, GenerateOpts } from '../types.js' +import type { ProviderResult, ProviderEvent, AIProvider, GenerateOpts } from '../types.js' +import type { SessionEntry } from '../../core/session.js' import type { ClaudeCodeConfig } from './types.js' +import { toTextHistory } from '../../core/session.js' +import { buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../utils.js' import { readAgentConfig } from '../../core/config.js' import { createChannel } from '../../core/async-channel.js' import { askClaudeCode } from './provider.js' export class ClaudeCodeProvider implements AIProvider { - readonly inputKind = 'text' as const readonly providerTag = 'claude-code' as const constructor( @@ -39,8 +41,10 @@ export class ClaudeCodeProvider implements AIProvider { return { text: result.text, media: [] } } - async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator { - if (input.kind !== 'text') throw new Error('ClaudeCodeProvider expects text input') + async *generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncGenerator { + const maxHistory = opts?.maxHistoryEntries ?? DEFAULT_MAX_HISTORY + const textHistory = toTextHistory(entries).slice(-maxHistory) + const fullPrompt = buildChatHistoryPrompt(prompt, textHistory, opts?.historyPreamble) const config = await this.resolveConfig() const claudeCode: ClaudeCodeConfig = { @@ -48,12 +52,12 @@ export class ClaudeCodeProvider implements AIProvider { ...(opts?.disabledTools?.length ? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] } : {}), - systemPrompt: input.systemPrompt ?? this.systemPrompt, + systemPrompt: opts?.systemPrompt ?? this.systemPrompt, } const channel = createChannel() - const resultPromise = askClaudeCode(input.prompt, { + const resultPromise = askClaudeCode(fullPrompt, { ...claudeCode, onToolUse: ({ id, name, input: toolInput }) => { channel.push({ type: 'tool_use', id, name, input: toolInput }) diff --git a/src/ai-providers/mock/index.ts b/src/ai-providers/mock/index.ts index 75c3dc7e..39e61b0f 100644 --- a/src/ai-providers/mock/index.ts +++ b/src/ai-providers/mock/index.ts @@ -18,20 +18,21 @@ * expect(provider.askCalls).toHaveLength(0) */ -import type { AIProvider, ProviderEvent, ProviderResult, GenerateInput, GenerateOpts } from './types.js' +import type { AIProvider, ProviderEvent, ProviderResult, GenerateOpts } from './types.js' +import type { SessionEntry } from '../core/session.js' import type { MediaAttachment } from '../core/types.js' // ==================== Call Records ==================== export interface MockAIProviderCall { - input: GenerateInput + entries: SessionEntry[] + prompt: string opts?: GenerateOpts } // ==================== Options ==================== export interface MockAIProviderOpts { - inputKind?: 'text' | 'messages' providerTag?: 'vercel-ai' | 'claude-code' | 'agent-sdk' /** Text returned by ask(). Default: 'mock-ask-result'. */ askResult?: string @@ -40,7 +41,6 @@ export interface MockAIProviderOpts { // ==================== MockAIProvider ==================== export class MockAIProvider implements AIProvider { - readonly inputKind: 'text' | 'messages' readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' readonly generateCalls: MockAIProviderCall[] = [] readonly askCalls: string[] = [] @@ -50,7 +50,6 @@ export class MockAIProvider implements AIProvider { private events: ProviderEvent[], opts?: MockAIProviderOpts, ) { - this.inputKind = opts?.inputKind ?? 'messages' this.providerTag = opts?.providerTag ?? 'vercel-ai' this._askResult = opts?.askResult ?? 'mock-ask-result' } @@ -60,8 +59,8 @@ export class MockAIProvider implements AIProvider { return { text: this._askResult, media: [] } } - async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncIterable { - this.generateCalls.push({ input, opts }) + async *generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncIterable { + this.generateCalls.push({ entries, prompt, opts }) for (const e of this.events) yield e } } diff --git a/src/ai-providers/types.ts b/src/ai-providers/types.ts index f5d24a82..0dd007df 100644 --- a/src/ai-providers/types.ts +++ b/src/ai-providers/types.ts @@ -1,4 +1,4 @@ -import type { ISessionStore, SDKModelMessage } from '../core/session.js' +import type { ISessionStore, SessionEntry } from '../core/session.js' import type { CompactionConfig, CompactionResult } from '../core/compaction.js' import type { MediaAttachment } from '../core/types.js' @@ -19,46 +19,39 @@ export interface ProviderResult { mediaUrls?: string[] } -// ==================== GenerateProvider ==================== - -/** - * Input prepared by AgentCenter, dispatched by provider.inputKind. - * - * - 'text': Claude Code / Agent SDK — single string prompt with baked in. - * - 'messages': Vercel AI SDK — structured ModelMessage[] (history carried natively). - */ -export type GenerateInput = - | { kind: 'text'; prompt: string; systemPrompt?: string } - | { kind: 'messages'; messages: SDKModelMessage[]; systemPrompt?: string } +// ==================== GenerateOpts ==================== /** Per-request options passed through to the underlying provider. */ export interface GenerateOpts { + /** System prompt override for this call. */ + systemPrompt?: string + /** Preamble text for chat history (text providers only). */ + historyPreamble?: string + /** Max history entries to include (text providers only). */ + maxHistoryEntries?: number disabledTools?: string[] vercelAiSdk?: { provider: string; model: string; baseUrl?: string; apiKey?: string } agentSdk?: { model?: string; apiKey?: string; baseUrl?: string } } +// ==================== AIProvider ==================== + /** * Slim provider interface — pure data-source adapter. * - * Does NOT touch session management. AgentCenter prepares the input, - * the provider calls the backend and yields ProviderEvents. + * Receives raw session entries + current prompt. Each provider decides + * how to serialize history for its backend (text string, structured messages, etc.). */ export interface AIProvider { - /** Which input format this provider expects. */ - readonly inputKind: 'text' | 'messages' /** Session log provenance tag. */ readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' /** Stateless one-shot prompt (used for compaction summarization, etc.). */ ask(prompt: string): Promise /** Stream events from the backend. Yields tool_use/tool_result/text, then done. */ - generate(input: GenerateInput, opts?: GenerateOpts): AsyncIterable + generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncIterable /** * Optional: custom compaction strategy. If implemented, AgentCenter delegates * compaction to the provider instead of using the default compactIfNeeded. - * - * Use case: providers with native server-side compaction (e.g. Anthropic API - * compact-2026-01-12) can bypass the local JSONL-based summarization. */ compact?(session: ISessionStore, config: CompactionConfig): Promise } diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts index ad97fced..22f2af56 100644 --- a/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts @@ -92,9 +92,8 @@ describe('VercelAIProvider — per-request overrides', () => { mockCreateAgent.mockReturnValue(makeAgent() as any) // generate with disabledTools - const input = { kind: 'messages' as const, messages: [] } const events = [] - for await (const e of provider.generate(input, { disabledTools: ['toolB'] })) { + for await (const e of provider.generate([], 'test', { disabledTools: ['toolB'] })) { events.push(e) } @@ -113,8 +112,7 @@ describe('VercelAIProvider — per-request overrides', () => { mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) mockCreateAgent.mockReturnValue(makeAgent() as any) - const input = { kind: 'messages' as const, messages: [] } - for await (const _ of provider.generate(input, { vercelAiSdk: { modelId: 'claude-3-7' } as any })) { + for await (const _ of provider.generate([], 'test', { vercelAiSdk: { modelId: 'claude-3-7' } as any })) { // drain } @@ -122,30 +120,22 @@ describe('VercelAIProvider — per-request overrides', () => { }) }) -// ==================== generate() input validation ==================== +// ==================== generate() behavior ==================== -describe('VercelAIProvider — generate() input', () => { +describe('VercelAIProvider — generate()', () => { beforeEach(() => { vi.clearAllMocks() mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) mockCreateAgent.mockReturnValue(makeAgent() as any) }) - it('throws when input.kind is not "messages"', async () => { - const provider = makeProvider() - const input = { kind: 'text' as any, text: 'hello' } - const gen = provider.generate(input) - await expect(gen.next()).rejects.toThrow('expects messages input') - }) - it('yields done event with text from result', async () => { const provider = makeProvider() const agent = makeAgent('final answer') mockCreateAgent.mockReturnValue(agent as any) - const input = { kind: 'messages' as const, messages: [] } const events = [] - for await (const e of provider.generate(input)) { + for await (const e of provider.generate([], 'test')) { events.push(e) } @@ -158,9 +148,8 @@ describe('VercelAIProvider — generate() input', () => { mockCreateAgent.mockReturnValue(agent as any) const provider = makeProvider() - const input = { kind: 'messages' as const, messages: [] } await expect(async () => { - for await (const _ of provider.generate(input)) { + for await (const _ of provider.generate([], 'test')) { // drain } }).rejects.toThrow('model error') diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts index aaaf1df1..a4ba59ea 100644 --- a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts @@ -7,16 +7,17 @@ */ import type { ModelMessage, Tool } from 'ai' -import type { ProviderResult, ProviderEvent, AIProvider, GenerateInput, GenerateOpts } from '../types.js' +import type { ProviderResult, ProviderEvent, AIProvider, GenerateOpts } from '../types.js' +import type { SessionEntry } from '../../core/session.js' import type { Agent } from './agent.js' import type { MediaAttachment } from '../../core/types.js' +import { toModelMessages } from '../../core/session.js' import { extractMediaFromToolOutput } from '../../core/media.js' import { createModelFromConfig, type ModelOverride } from './model-factory.js' import { createAgent } from './agent.js' import { createChannel } from '../../core/async-channel.js' export class VercelAIProvider implements AIProvider { - readonly inputKind = 'messages' as const readonly providerTag = 'vercel-ai' as const private cachedKey: string | null = null private cachedToolCount: number = 0 @@ -69,16 +70,16 @@ export class VercelAIProvider implements AIProvider { return { text: result.text ?? '', media } } - async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator { - if (input.kind !== 'messages') throw new Error('VercelAIProvider expects messages input') + async *generate(entries: SessionEntry[], _prompt: string, opts?: GenerateOpts): AsyncGenerator { + const messages = toModelMessages(entries) - const agent = await this.resolveAgent(input.systemPrompt, opts?.disabledTools, opts?.vercelAiSdk) + const agent = await this.resolveAgent(opts?.systemPrompt, opts?.disabledTools, opts?.vercelAiSdk) const channel = createChannel() const media: MediaAttachment[] = [] const resultPromise = agent.generate({ - messages: input.messages as ModelMessage[], + messages: messages as ModelMessage[], onStepFinish: (step) => { for (const tc of step.toolCalls) { channel.push({ type: 'tool_use', id: tc.toolCallId, name: tc.toolName, input: tc.input }) diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index 8cbc4bb2..d320de4b 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -6,21 +6,21 @@ * * Providers are slim data-source adapters; all shared logic lives here: * - Session management (append, compact, read active) - * - Input format dispatch (text vs messages based on provider.inputKind) * - Unified pipeline (logToolCall, stripImageData, extractMedia) * - Message persistence (intermediate tool messages + final response) + * + * History serialization (text vs messages) is each provider's responsibility. */ import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider-manager.js' import { GenerateRouter, StreamableResult } from './ai-provider-manager.js' import type { ISessionStore, ContentBlock } from './session.js' -import { toTextHistory, toModelMessages } from './session.js' import type { CompactionConfig } from './compaction.js' import { compactIfNeeded } from './compaction.js' import type { MediaAttachment } from './types.js' import { extractMediaFromToolResultContent } from './media.js' import { persistMedia } from './media-store.js' -import { logToolCall, stripImageData, buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../ai-providers/utils.js' +import { logToolCall, stripImageData, DEFAULT_MAX_HISTORY } from '../ai-providers/utils.js' // ==================== Types ==================== @@ -65,9 +65,6 @@ export class AgentCenter { session: ISessionStore, opts?: AskOptions, ): AsyncGenerator { - const maxHistory = opts?.maxHistoryEntries ?? this.defaultMaxHistory - const preamble = opts?.historyPreamble ?? this.defaultPreamble - // 1. Append user message to session await session.appendUser(prompt, 'human') @@ -86,28 +83,16 @@ export class AgentCenter { // 4. Read active window const entries = compactionResult.activeEntries ?? await session.readActive() - // 5. Build input based on provider.inputKind + // 5. Delegate to provider — each provider decides how to serialize history const genOpts: GenerateOpts = { + systemPrompt: opts?.systemPrompt, + historyPreamble: opts?.historyPreamble ?? this.defaultPreamble, + maxHistoryEntries: opts?.maxHistoryEntries ?? this.defaultMaxHistory, disabledTools: opts?.disabledTools, vercelAiSdk: opts?.vercelAiSdk, agentSdk: opts?.agentSdk, } - - let source: AsyncIterable - if (provider.inputKind === 'text') { - const textHistory = toTextHistory(entries).slice(-maxHistory) - const fullPrompt = buildChatHistoryPrompt(prompt, textHistory, preamble) - source = provider.generate( - { kind: 'text', prompt: fullPrompt, systemPrompt: opts?.systemPrompt }, - genOpts, - ) - } else { - const messages = toModelMessages(entries) - source = provider.generate( - { kind: 'messages', messages, systemPrompt: opts?.systemPrompt }, - genOpts, - ) - } + const source = provider.generate(entries, prompt, genOpts) // 6. Consume provider events — unified pipeline const media: MediaAttachment[] = [] diff --git a/src/core/ai-provider-manager.spec.ts b/src/core/ai-provider-manager.spec.ts index 94213cd2..39b39775 100644 --- a/src/core/ai-provider-manager.spec.ts +++ b/src/core/ai-provider-manager.spec.ts @@ -132,7 +132,6 @@ describe('StreamableResult', () => { describe('GenerateRouter', () => { function makeProvider(tag: AIProvider['providerTag']): AIProvider { return { - inputKind: tag === 'vercel-ai' ? 'messages' : 'text', providerTag: tag, ask: vi.fn(async () => ({ text: `from-${tag}`, media: [] })), async *generate() { yield { type: 'done' as const, result: { text: '', media: [] } } }, diff --git a/src/core/ai-provider-manager.ts b/src/core/ai-provider-manager.ts index fe6e63fe..7d7da37a 100644 --- a/src/core/ai-provider-manager.ts +++ b/src/core/ai-provider-manager.ts @@ -11,7 +11,7 @@ import type { ProviderEvent, ProviderResult, AIProvider } from '../ai-providers/ export type { ProviderEvent, ProviderResult, AIProvider, - GenerateInput, GenerateOpts, + GenerateOpts, } from '../ai-providers/types.js' // ==================== StreamableResult ==================== diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 31cd33f6..72ac8991 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -74,8 +74,11 @@ describe('toModelMessages', () => { { type: 'text', text: 'thinking...' }, { type: 'tool_use', id: 't1', name: 'Read', input: { path: '/tmp' } }, ]), + userBlocks([ + { type: 'tool_result', tool_use_id: 't1', content: 'file contents' }, + ]), ]) - expect(msgs).toHaveLength(1) + expect(msgs).toHaveLength(2) expect(msgs[0].role).toBe('assistant') const content = (msgs[0] as { content: unknown[] }).content expect(content).toHaveLength(2) @@ -144,6 +147,42 @@ describe('toModelMessages', () => { expect(msgs[2].role).toBe('tool') expect(msgs[3].role).toBe('assistant') }) + + it('should strip orphaned tool-call entries that have no matching tool-result', () => { + const msgs = toModelMessages([ + userText('do two things'), + // Assistant calls two tools, but only one result exists + assistantBlocks([ + { type: 'tool_use', id: 't1', name: 'ToolA', input: {} }, + { type: 'tool_use', id: 't2', name: 'ToolB', input: {} }, + ]), + // Only t1 has a result — t2 is orphaned (e.g. session interrupted) + userBlocks([ + { type: 'tool_result', tool_use_id: 't1', content: 'result A' }, + ]), + assistantText('done'), + ]) + // Assistant message should only contain the tool-call for t1 + expect(msgs).toHaveLength(4) + const assistantContent = (msgs[1] as { content: unknown[] }).content + expect(assistantContent).toHaveLength(1) + expect((assistantContent[0] as { toolCallId: string }).toolCallId).toBe('t1') + }) + + it('should drop assistant message entirely if all tool-calls are orphaned', () => { + const msgs = toModelMessages([ + userText('hi'), + // Assistant only has tool_use, no results exist at all + assistantBlocks([ + { type: 'tool_use', id: 'orphan1', name: 'Missing', input: {} }, + ]), + assistantText('recovered'), + ]) + // The orphaned assistant message should be dropped + expect(msgs).toHaveLength(2) + expect(msgs[0]).toEqual({ role: 'user', content: 'hi' }) + expect(msgs[1]).toEqual({ role: 'assistant', content: 'recovered' }) + }) }) // ==================== toTextHistory ==================== diff --git a/src/core/session.ts b/src/core/session.ts index 7eeab828..cb2e8499 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -375,7 +375,39 @@ export function toModelMessages(entries: SessionEntry[]): SDKModelMessage[] { // system role messages (non-boundary) are skipped — they don't map to SDK messages } - return messages + // Sanitize: remove tool-call entries that have no matching tool-result. + // This can happen when compaction truncates between a tool_use and its result, + // or when a session was interrupted mid-tool-call. + return sanitizeToolMessages(messages) +} + +/** Strip orphaned tool-call entries that have no matching tool-result downstream. */ +function sanitizeToolMessages(messages: SDKModelMessage[]): SDKModelMessage[] { + // Collect all tool-result IDs + const resultIds = new Set() + for (const msg of messages) { + if (msg.role === 'tool') { + for (const part of msg.content) { + resultIds.add(part.toolCallId) + } + } + } + + const out: SDKModelMessage[] = [] + for (const msg of messages) { + if (msg.role === 'assistant' && Array.isArray(msg.content)) { + const filtered = msg.content.filter( + (part) => part.type !== 'tool-call' || resultIds.has(part.toolCallId), + ) + if (filtered.length > 0) { + out.push({ ...msg, content: filtered }) + } + // Drop assistant message entirely if only orphaned tool-calls remained + } else { + out.push(msg) + } + } + return out } /** Max characters for a tool input/output summary line. */