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
16 changes: 10 additions & 6 deletions src/ai-providers/agent-sdk/agent-sdk-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -49,16 +51,18 @@ export class AgentSdkProvider implements AIProvider {
return { text: result.text, media: [] }
}

async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator<ProviderEvent> {
if (input.kind !== 'text') throw new Error('AgentSdkProvider expects text input')
async *generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncGenerator<ProviderEvent> {
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 = {
...config,
...(opts?.disabledTools?.length
? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] }
: {}),
systemPrompt: input.systemPrompt ?? this.systemPrompt,
systemPrompt: opts?.systemPrompt ?? this.systemPrompt,
}

const override: AgentSdkOverride | undefined = opts?.agentSdk
Expand All @@ -67,7 +71,7 @@ export class AgentSdkProvider implements AIProvider {
const channel = createChannel<ProviderEvent>()

const resultPromise = askAgentSdk(
input.prompt,
fullPrompt,
{
...agentSdkConfig,
onToolUse: ({ id, name, input: toolInput }) => {
Expand Down
16 changes: 10 additions & 6 deletions src/ai-providers/claude-code/claude-code-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -39,21 +41,23 @@ export class ClaudeCodeProvider implements AIProvider {
return { text: result.text, media: [] }
}

async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator<ProviderEvent> {
if (input.kind !== 'text') throw new Error('ClaudeCodeProvider expects text input')
async *generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncGenerator<ProviderEvent> {
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 = {
...config,
...(opts?.disabledTools?.length
? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] }
: {}),
systemPrompt: input.systemPrompt ?? this.systemPrompt,
systemPrompt: opts?.systemPrompt ?? this.systemPrompt,
}

const channel = createChannel<ProviderEvent>()

const resultPromise = askClaudeCode(input.prompt, {
const resultPromise = askClaudeCode(fullPrompt, {
...claudeCode,
onToolUse: ({ id, name, input: toolInput }) => {
channel.push({ type: 'tool_use', id, name, input: toolInput })
Expand Down
13 changes: 6 additions & 7 deletions src/ai-providers/mock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[] = []
Expand All @@ -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'
}
Expand All @@ -60,8 +59,8 @@ export class MockAIProvider implements AIProvider {
return { text: this._askResult, media: [] }
}

async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncIterable<ProviderEvent> {
this.generateCalls.push({ input, opts })
async *generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncIterable<ProviderEvent> {
this.generateCalls.push({ entries, prompt, opts })
for (const e of this.events) yield e
}
}
Expand Down
33 changes: 13 additions & 20 deletions src/ai-providers/types.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 <chat_history> 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<ProviderResult>
/** Stream events from the backend. Yields tool_use/tool_result/text, then done. */
generate(input: GenerateInput, opts?: GenerateOpts): AsyncIterable<ProviderEvent>
generate(entries: SessionEntry[], prompt: string, opts?: GenerateOpts): AsyncIterable<ProviderEvent>
/**
* 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<CompactionResult>
}
23 changes: 6 additions & 17 deletions src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -113,39 +112,30 @@ 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
}

expect(mockCreateAgent).toHaveBeenCalledOnce()
})
})

// ==================== 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)
}

Expand All @@ -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')
Expand Down
13 changes: 7 additions & 6 deletions src/ai-providers/vercel-ai-sdk/vercel-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,16 +70,16 @@ export class VercelAIProvider implements AIProvider {
return { text: result.text ?? '', media }
}

async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator<ProviderEvent> {
if (input.kind !== 'messages') throw new Error('VercelAIProvider expects messages input')
async *generate(entries: SessionEntry[], _prompt: string, opts?: GenerateOpts): AsyncGenerator<ProviderEvent> {
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<ProviderEvent>()
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 })
Expand Down
31 changes: 8 additions & 23 deletions src/core/agent-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ====================

Expand Down Expand Up @@ -65,9 +65,6 @@ export class AgentCenter {
session: ISessionStore,
opts?: AskOptions,
): AsyncGenerator<ProviderEvent> {
const maxHistory = opts?.maxHistoryEntries ?? this.defaultMaxHistory
const preamble = opts?.historyPreamble ?? this.defaultPreamble

// 1. Append user message to session
await session.appendUser(prompt, 'human')

Expand All @@ -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<ProviderEvent>
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[] = []
Expand Down
1 change: 0 additions & 1 deletion src/core/ai-provider-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] } } },
Expand Down
Loading
Loading