Skip to content
Merged
72 changes: 6 additions & 66 deletions apps/cli/ai/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ import fs from 'fs';
import {
AI_MODELS,
DEFAULT_MODEL,
getAiModelFamily,
getAiModelLabel,
type AiModelId,
} from '@studio/common/ai/models';
import { anthropicRuntime } from 'cli/ai/runtimes/anthropic';
import { isOpenAIRuntimeSession, openaiRuntime } from 'cli/ai/runtimes/openai';
import { piRuntime } from 'cli/ai/runtimes/pi';
import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths';
import type { AgentRuntime, AgentRuntimeHandle } from 'cli/ai/runtimes/types';
import type { AgentRuntimeHandle } from 'cli/ai/runtimes/types';
import type { SiteInfo } from 'cli/ai/ui';

export { AI_MODELS, DEFAULT_MODEL, getAiModelLabel, type AiModelId };
Expand All @@ -32,53 +30,11 @@ export interface AiAgentConfig {
activeSite?: SiteInfo | null;
wpcomAccessToken?: string;
onAskUser?: AskUserHandler;
/**
* Absolute path to the JSONL the recorder is writing to. Optional —
* the OpenAI runtime uses it to load/save its sidecar transcript so
* pi-agent-core memory survives across CLI process forks (the desktop
* UI forks per turn). When omitted, the OpenAI runtime keeps state in
* memory only and forgets across forks.
*/
// JSONL path the recorder writes to; the runtime stashes a sidecar
// transcript next to it so pi-agent-core state survives CLI process forks.
sessionFilePath?: string;
}

// The Claude Agent SDK rejects internal pending promises (e.g. control
// responses) when an agent turn is interrupted via ESC. These rejections
// are unhandled because they originate inside the SDK cleanup path rather
// than propagating through the async iterator. Without this handler,
// Node.js terminates the process on unhandled rejections.
const SDK_INTERRUPT_CLEANUP_ERRORS = [
'Query closed',
'ProcessTransport is not ready for writing',
];
process.on( 'unhandledRejection', ( reason ) => {
if (
reason instanceof Error &&
SDK_INTERRUPT_CLEANUP_ERRORS.some( ( msg ) => reason.message.includes( msg ) )
) {
return;
}
throw reason;
} );

/**
* Pick the runtime based on the selected model's family. GPT-* models route to
* the OpenAI runtime; everything else defaults to the Claude Agent SDK path.
*
* Matches the user-facing mental model: `/model` is the control, and whichever
* model you pick dictates which backend handles the turn — independent of
* which provider supplied the credentials.
*/
function pickRuntime( model: AiModelId ): AgentRuntime {
return getAiModelFamily( model ) === 'openai' ? openaiRuntime : anthropicRuntime;
}

/**
* Start the AI agent and return a handle that streams messages until the
* turn completes. Caller can iterate with `for await` and call `interrupt()`
* to stop. The concrete runtime (Anthropic vs OpenAI) is chosen based on the
* resolved environment.
*/
export function startAiAgent( config: AiAgentConfig ): AgentRuntimeHandle {
const {
prompt,
Expand All @@ -96,27 +52,11 @@ export function startAiAgent( config: AiAgentConfig ): AgentRuntimeHandle {
fs.mkdirSync( STUDIO_SITES_ROOT, { recursive: true } );
}

const runtime = pickRuntime( model );
// Cross-family resume guard. If the caller passes a `resume` id that was
// issued by the OpenAI runtime but we're now routing to the Anthropic
// runtime (e.g. user picked a different family in the model dropdown
// mid-session), the Claude Agent SDK doesn't recognize it and errors with
// "No conversation found". Drop the resume hint instead — the new runtime
// starts a fresh agent thread, the session record on disk continues
// accumulating turns. The OpenAI direction handles unknown resume ids
// silently (see runtimes/openai/index.ts), so we only need to gate the
// Anthropic side. In-memory tracking; survives a single process lifetime
// only (see comment on isOpenAIRuntimeSession).
const safeResume =
resume && getAiModelFamily( model ) === 'anthropic' && isOpenAIRuntimeSession( resume )
? undefined
: resume;

return runtime.run( {
return piRuntime.run( {
prompt,
env: resolvedEnv,
model,
resume: safeResume,
resume,
activeSite,
wpcomAccessToken,
onAskUser,
Expand Down
8 changes: 3 additions & 5 deletions apps/cli/ai/eval-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import {
resolveInitialAiProvider,
resolveUnavailableAiProvider,
} from 'cli/ai/auth';
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AiProviderId } from 'cli/ai/providers';
import type { SDKMessage } from 'cli/ai/runtimes/messages';

interface EvalRunnerInput {
prompt: string;
Expand All @@ -36,12 +36,10 @@ function extractToolCalls( message: SDKMessage ) {
const content = message.message.content ?? [];
return content
.filter(
( block: {
type: string;
} ): block is { type: 'tool_use'; id: string; name: string; input: unknown } =>
( block ): block is Extract< ( typeof content )[ number ], { type: 'tool_use' } > =>
block.type === 'tool_use'
)
.map( ( block: { id: string; name: string; input: unknown } ) => ( {
.map( ( block ) => ( {
id: block.id,
name: normalizeToolName( block.name ),
input: block.input,
Expand Down
52 changes: 47 additions & 5 deletions apps/cli/ai/mcp-server.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,58 @@
// eslint-disable-next-line import-x/no-unresolved -- subpath resolved via package's wildcard export, which the lint resolver doesn't follow
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createStudioTools } from 'cli/ai/tools';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
// eslint-disable-next-line import-x/no-unresolved -- subpath resolved via package's wildcard export, which the lint resolver doesn't follow
} from '@modelcontextprotocol/sdk/types.js';
import { resolveStudioToolDefinitions } from 'cli/ai/tools';
import type { StudioAgentTool } from 'cli/ai/tools/define-tool';

// Uses the low-level Server API rather than McpServer.registerTool, which only
// accepts zod-shaped inputs — our tools are typebox JSON Schema.
export async function startMcpStdioServer(): Promise< void > {
const studioMcp = createStudioTools();
const transport = new StdioServerTransport();
const tools = resolveStudioToolDefinitions() as StudioAgentTool[];
const toolsByName = new Map( tools.map( ( tool ) => [ tool.name, tool ] ) );

const server = new Server(
{ name: 'studio', version: '1.0.0' },
{ capabilities: { tools: {} } }
);

server.setRequestHandler( ListToolsRequestSchema, async () => ( {
tools: tools.map( ( tool ) => ( {
name: tool.name,
description: tool.description,
inputSchema: tool.parameters as unknown as Record< string, unknown >,
} ) ),
} ) );

server.setRequestHandler( CallToolRequestSchema, async ( request ) => {
const tool = toolsByName.get( request.params.name );
if ( ! tool ) {
throw new Error( `Unknown tool: ${ request.params.name }` );
}
// MCP wants `{content, isError}`; pi-native handlers throw — translate.
try {
const result = await tool.rawHandler( ( request.params.arguments ?? {} ) as never );
return { content: result.content, isError: false };
} catch ( error ) {
const message = error instanceof Error ? error.message : String( error );
return {
content: [ { type: 'text' as const, text: message } ],
isError: true,
};
}
} );

const transport = new StdioServerTransport();
const shutdown = async () => {
await studioMcp.instance.close();
await server.close();
process.exit( 0 );
};
process.on( 'SIGINT', shutdown );
process.on( 'SIGTERM', shutdown );

await studioMcp.instance.connect( transport );
await server.connect( transport );
}
2 changes: 1 addition & 1 deletion apps/cli/ai/output-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DEFAULT_MODEL, type AiModelId, type AskUserQuestion } from 'cli/ai/agent';
import { emitEvent, type TurnCompletedStatus } from 'cli/ai/json-events';
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AiProviderId } from 'cli/ai/providers';
import type { SDKMessage } from 'cli/ai/runtimes/messages';
import type { SiteInfo } from 'cli/ai/ui';

export type HandleMessageResult = {
Expand Down
5 changes: 0 additions & 5 deletions apps/cli/ai/plugin/.claude-plugin/plugin.json

This file was deleted.

121 changes: 0 additions & 121 deletions apps/cli/ai/runtimes/anthropic/index.ts

This file was deleted.

Loading
Loading