openpencil/apps/web/server/api/ai/agent.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

1194 lines
47 KiB
TypeScript

import { defineEventHandler, readBody, setResponseHeaders, getQuery, createError } from 'h3';
import {
createAnthropicProvider,
createOpenAICompatProvider,
createToolRegistry,
registerToolSchema,
createQueryEngine,
seedMessages,
submitMessage,
nextEvent,
resolveToolResult,
createTeam,
runTeam,
addTeamMember,
resolveTeamToolResult,
teamRegisterDelegate,
runTeamMember,
destroyIterator,
resolveMemberToolResult,
seedTeamMessages,
} from '@zseven-w/agent-native';
import { resolveSkills } from '@zseven-w/pen-ai-skills';
import type { Phase } from '@zseven-w/pen-ai-skills';
import type { AuthLevel } from '../../../src/types/agent';
import {
agentSessions,
cleanup,
abortSession,
createSession,
createAcpSession,
touchSession,
type AgentSession,
type NativeAgentSession,
} from '../../utils/agent-sessions';
import {
shouldShortCircuitPlanLayout,
updateLayoutSessionState,
} from '../../utils/agent-tool-guard';
import { getAllToolDefs } from '../../../src/services/ai/agent-tools';
import {
normalizeOptionalBaseURL,
normalizeMemberBaseURL,
requireOpenAICompatBaseURL,
} from './provider-url';
import { startSSEKeepAlive } from '../../utils/sse-keepalive';
import { getAcpConnection } from '../../utils/acp-connection-manager';
import { getMcpServerStatus } from '../../utils/mcp-server-manager';
import { acpUpdateToSSE } from '@zseven-w/pen-acp';
const TOOL_LEVEL_MAP: Record<string, AuthLevel> = {
batch_get: 'read',
snapshot_layout: 'read',
find_empty_space: 'read',
generate_design: 'create',
insert_node: 'create',
update_node: 'modify',
delete_node: 'delete',
};
const ROLE_TOOL_PRESETS: Record<string, string[]> = {
designer: [
'batch_get',
'snapshot_layout',
'find_empty_space',
'generate_design',
'insert_node',
'plan_layout',
'batch_insert',
],
reviewer: ['batch_get', 'snapshot_layout', 'get_selection'],
editor: [
'batch_get',
'snapshot_layout',
'find_empty_space',
'update_node',
'delete_node',
'insert_node',
],
researcher: ['batch_get', 'snapshot_layout', 'find_empty_space', 'get_selection'],
};
const ROLE_SKILL_PHASE: Record<string, Phase> = {
designer: 'generation',
reviewer: 'validation',
editor: 'maintenance',
researcher: 'planning',
};
const ROLE_TOOL_INSTRUCTIONS: Record<string, string> = {
designer: `You are a design team member. When asked to create designs, you MUST call the generate_design tool with a descriptive prompt. You can also use insert_node for manual node creation, batch_get and snapshot_layout to inspect the canvas, and find_empty_space to find placement locations. Always end with a short natural-language summary of what you created or changed. Never stop at tool calls only.`,
reviewer: `You are a design reviewer. Use batch_get and snapshot_layout to inspect the current canvas state. Use get_selection to see what the user has selected. Provide detailed feedback on layout, spacing, typography, and visual hierarchy. Always end with a short natural-language summary for the lead agent.`,
editor: `You are a design editor. ALWAYS start by calling batch_get or snapshot_layout to understand the current canvas state before making changes. Match your action to user intent:
- To READ/INSPECT: use batch_get (search nodes) or snapshot_layout (spatial overview)
- To DELETE/REMOVE: use batch_get to find the node ID, then delete_node to remove it — do NOT create new nodes
- To MODIFY: use update_node to change properties of existing nodes
- To ADD: use insert_node to add new elements, find_empty_space for placement
Always end with a short natural-language summary of what changed. Never stop at tool calls only.`,
researcher: `You are a design researcher. Use batch_get and snapshot_layout to analyze the current canvas state. Use find_empty_space to identify available space. Use get_selection to see what the user has selected. Provide analysis and recommendations. Always end with a short natural-language summary for the lead agent.`,
};
function buildTeamCapabilitiesPrompt(concurrency: number): string {
return `\n\n## Team Mode — MANDATORY parallel design
You MUST use your team of ${concurrency} designers. Do NOT call generate_design yourself.
**Workflow:**
1. Analyze the user's request and break it into ${concurrency} distinct sections/screens
2. Spawn ${concurrency} designer members: spawn_member({id: "designer-1", role: "designer"}), spawn_member({id: "designer-2", role: "designer"}), etc.
3. Delegate one section to each: delegate({member_id: "designer-1", task: "Design the [section] with [details]..."})
4. After all delegations complete, summarize what was created.
**Available roles:** designer, reviewer, editor, researcher
**Example for a food app with ${concurrency} designers:**
${Array.from({ length: concurrency }, (_, i) => `- designer-${i + 1}: a different screen or section`).join('\n')}
IMPORTANT: Always spawn exactly ${concurrency} designers and delegate to all of them. Each delegation should include a detailed description of that section. Never call generate_design directly — always delegate to spawned designers.
After all delegations, end with a short summary for the user.`;
}
function buildMemberSystemPrompt(
role: string,
designMdContent?: string,
hasVariables?: boolean,
): string {
const phase = ROLE_SKILL_PHASE[role] ?? 'generation';
const toolInstructions = ROLE_TOOL_INSTRUCTIONS[role] ?? '';
const skillCtx = resolveSkills(phase, '', {
flags: {
hasDesignMd: !!designMdContent,
hasVariables: !!hasVariables,
},
dynamicContent: designMdContent ? { designMdContent } : undefined,
});
const knowledge = skillCtx.skills.map((s) => s.content).join('\n\n');
return `${toolInstructions}\n\n${knowledge}`;
}
const SPAWN_MEMBER_SCHEMA = JSON.stringify({
type: 'object',
properties: {
id: { type: 'string', description: 'Unique member ID, e.g. "designer-1"' },
role: {
type: 'string',
enum: ['designer', 'reviewer', 'editor', 'researcher'],
description: 'Member role — determines available tools and knowledge',
},
model: {
type: 'string',
description: 'Optional model override for this member. Defaults to lead model.',
},
},
required: ['id', 'role'],
});
interface ToolDef {
name: string;
description: string;
level: AuthLevel;
parameters?: Record<string, unknown>;
}
interface MemberDef {
id: string;
providerType: 'anthropic' | 'openai-compat';
apiKey: string;
model: string;
baseURL?: string;
systemPrompt?: string;
}
interface AgentBody {
sessionId: string;
messages: Array<{ role: string; content: string }>;
systemPrompt: string;
providerType: 'anthropic' | 'openai-compat' | 'acp';
apiKey: string;
model: string;
baseURL?: string;
toolDefs: ToolDef[];
maxTurns?: number;
maxOutputTokens?: number;
maxContextTokens?: number;
members?: MemberDef[];
teamMode?: boolean;
concurrency?: number;
designMdContent?: string;
hasVariables?: boolean;
acpAgentId?: string;
acpConfig?: import('../../../src/types/agent-settings').AcpAgentConfig;
}
/** Map Zig event JSON to client SSE format.
* Zig events are tagged unions: {"result":{...}} or {"stream_event":{...}}.
* Extract the tag and inner data, then map to the flat client format.
*/
function zigEventToSSE(raw: string): string {
const evt = JSON.parse(raw);
// Zig tagged union: the single key is the event type, value is the data.
// For stream_event, the inner object has its own "type" field (text_delta, etc.)
let tag: string;
let data: Record<string, unknown>;
if (evt.tool_use) {
// Complete tool call from Zig engine (after input_json_delta accumulation).
// This is the authoritative tool_call event — content_block_start only has metadata.
tag = 'tool_use';
data = evt.tool_use;
} else if (evt.stream_event) {
tag = evt.stream_event.type ?? 'unknown';
data = evt.stream_event;
} else if (evt.result) {
tag = 'result';
data = evt.result;
} else if (evt.tool_progress) {
tag = 'tool_progress';
data = evt.tool_progress;
} else {
tag = evt.type ?? 'unknown';
data = evt;
}
let mapped: Record<string, unknown>;
switch (tag) {
case 'text_delta':
mapped = { type: 'text', content: data.text };
break;
case 'thinking_delta':
mapped = { type: 'thinking', content: data.text };
break;
case 'tool_use':
// Complete tool call with full args — emitted by Zig engine after input_json_delta accumulation
mapped = {
type: 'tool_call',
id: data.id,
name: data.name,
args:
typeof data.input === 'string' ? JSON.parse(data.input as string) : (data.input ?? {}),
level: TOOL_LEVEL_MAP[data.name as string] ?? 'read',
};
break;
case 'content_block_start':
// Skip tool_use content_block_start — args aren't available yet.
// The complete tool_call is emitted later as a tool_use event.
if (data.tool_name) {
return ''; // suppress — will come as tool_use event with full args
}
mapped = { type: tag, ...data };
break;
case 'result':
if (data.is_error) {
const parts: string[] = [`Agent error: ${data.subtype ?? 'unknown'}`];
if (data.result) parts.push(String(data.result));
// Provider-captured upstream errors (HTTP body from anthropic /
// openai_compat). Often a JSON envelope — show the inner message
// when we can parse it; otherwise dump the raw body.
if (Array.isArray(data.errors)) {
for (const raw of data.errors as unknown[]) {
const text = typeof raw === 'string' ? raw : JSON.stringify(raw);
let pretty = text;
try {
const obj = JSON.parse(text);
const inner = obj?.error?.message ?? obj?.message ?? obj?.error?.type;
if (typeof inner === 'string' && inner.length > 0) pretty = inner;
} catch {
/* not JSON — keep raw */
}
parts.push(pretty.length > 600 ? pretty.slice(0, 600) + '…' : pretty);
}
}
mapped = {
type: 'error',
message: parts.join(' — '),
fatal: true,
};
} else {
mapped = { type: 'done', totalTurns: data.num_turns ?? 0 };
}
break;
case 'member_start':
mapped = {
type: 'member_start',
memberId: data.member_id,
task: data.task ?? '',
};
break;
case 'member_end':
mapped = {
type: 'member_end',
memberId: data.member_id,
result: data.result ?? '',
};
break;
default:
mapped = { type: tag, ...data };
}
return `event: ${mapped.type}\ndata: ${JSON.stringify(mapped)}\n\n`;
}
/** Run a delegated member asynchronously — does NOT block the caller.
* Only called for native agent sessions (team mode). */
async function runDelegateMember(
session: NativeAgentSession,
body: AgentBody,
controller: ReadableStreamDefaultController,
encoder: TextEncoder,
toolUseId: string,
memberId: string,
task: string,
) {
// Resolve task-specific skills based on member role
const memberRole = session.memberRoles.get(memberId);
let enrichedTask = task;
if (memberRole) {
const phase = ROLE_SKILL_PHASE[memberRole] ?? 'generation';
const taskSkills = resolveSkills(phase, task, {
flags: {
hasDesignMd: !!body.designMdContent,
hasVariables: !!body.hasVariables,
},
});
const skillPrefix = taskSkills.skills.map((s) => s.content).join('\n\n');
if (skillPrefix) enrichedTask = skillPrefix + '\n\n' + task;
}
controller.enqueue(
encoder.encode(
`event: member_start\ndata: ${JSON.stringify({ type: 'member_start', memberId, task })}\n\n`,
),
);
let memberResult = '';
const memberIter = await runTeamMember(session.team!, memberId, enrichedTask);
try {
let memberRaw: string | null;
while ((memberRaw = await nextEvent(memberIter)) !== null) {
session.lastActivity = Date.now();
try {
const mEvt = JSON.parse(memberRaw);
// Member tool_use → record owner, forward with source
if (mEvt.tool_use) {
const mToolId = mEvt.tool_use.id;
session.toolOwners.set(mToolId, memberId);
const level = TOOL_LEVEL_MAP[mEvt.tool_use.name as string] ?? 'read';
const toolCallEvt = {
type: 'tool_call',
id: mToolId,
name: mEvt.tool_use.name,
args:
typeof mEvt.tool_use.input === 'string'
? JSON.parse(mEvt.tool_use.input as string)
: (mEvt.tool_use.input ?? {}),
level,
source: memberId,
};
controller.enqueue(
encoder.encode(`event: tool_call\ndata: ${JSON.stringify(toolCallEvt)}\n\n`),
);
continue;
}
// Collect text
if (mEvt.stream_event?.text && mEvt.stream_event.type === 'text_delta') {
memberResult += mEvt.stream_event.text;
}
} catch {
/* ignore parse errors */
}
const memberSse = zigEventToSSE(memberRaw);
if (memberSse) controller.enqueue(encoder.encode(memberSse));
}
} finally {
destroyIterator(memberIter);
for (const [tid, mid] of session.toolOwners) {
if (mid === memberId) session.toolOwners.delete(tid);
}
}
controller.enqueue(
encoder.encode(
`event: member_end\ndata: ${JSON.stringify({ type: 'member_end', memberId, result: '' })}\n\n`,
),
);
resolveTeamToolResult(
session.team!,
toolUseId,
JSON.stringify({ result: memberResult || 'Member completed task.' }),
);
}
function createProviderHandle(
providerType: 'anthropic' | 'openai-compat',
apiKey: string,
model: string,
baseURL?: string,
maxContextTokens?: number,
) {
return providerType === 'anthropic'
? createAnthropicProvider(apiKey, model, baseURL, maxContextTokens)
: createOpenAICompatProvider(
apiKey,
requireOpenAICompatBaseURL(baseURL),
model,
maxContextTokens,
);
}
/**
* Unified agent endpoint. Routes by `?action=` query param:
* POST /api/ai/agent — Start agent loop (SSE stream)
* POST /api/ai/agent?action=result — Resolve a pending tool call
* POST /api/ai/agent?action=abort — Abort an agent session
*/
export default defineEventHandler(async (event) => {
const { action } = getQuery(event) as { action?: string };
// ── Tool result callback ────────────────────────────────────
if (action === 'result') {
const body = await readBody<{ sessionId: string; toolCallId: string; result: any }>(event);
if (!body?.sessionId || !body.toolCallId || !body.result) {
throw createError({ statusCode: 400, message: 'Missing: sessionId, toolCallId, result' });
}
const session = agentSessions.get(body.sessionId);
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' });
}
try {
const toolName = session.toolNames.get(body.toolCallId);
updateLayoutSessionState(session, toolName, body.result);
// ACP sessions: tools are executed by the agent via MCP, not client-side.
// Just acknowledge the result and return.
if (session.type === 'acp') {
session.lastActivity = Date.now();
session.toolNames.delete(body.toolCallId);
return { ok: true };
}
const resultJson = JSON.stringify(body.result);
// Per-toolCallId routing: check if this tool belongs to a member
const memberId = session.toolOwners?.get(body.toolCallId);
if (memberId && session.team) {
resolveMemberToolResult(session.team, memberId, body.toolCallId, resultJson);
session.toolOwners.delete(body.toolCallId);
} else if (session.team) {
resolveTeamToolResult(session.team, body.toolCallId, resultJson);
} else if (session.engine) {
resolveToolResult(session.engine, body.toolCallId, resultJson);
}
session.toolNames.delete(body.toolCallId);
} catch {
return { ok: true, ignored: true };
}
session.lastActivity = Date.now();
return { ok: true };
}
// ── Abort ───────────────────────────────────────────────────
if (action === 'abort') {
const body = await readBody<{ sessionId?: string }>(event);
const sid = body?.sessionId;
if (sid) {
const session = agentSessions.get(sid);
if (session) {
abortSession(session);
cleanup(session);
agentSessions.delete(sid);
}
}
return { ok: true };
}
// ── Start agent loop (SSE stream) ──────────────────────────
const body = await readBody<AgentBody>(event);
// ── ACP Agent path ──────────────────────────────────────────
if (body?.providerType === 'acp' && body.acpAgentId) {
let conn = getAcpConnection(body.acpAgentId as string);
// If connection missing (e.g. dev server restart) but we have the config,
// attempt to reconnect transparently using the config sent by the client.
if (!conn && (body as any).acpConfig) {
console.log(`[acp] connection missing, auto-reconnecting ${body.acpAgentId}`);
const { connectAcp } = await import('../../utils/acp-connection-manager');
const result = await connectAcp(body.acpAgentId as string, (body as any).acpConfig);
if (!result.connected) {
throw createError({
statusCode: 400,
message: `ACP agent auto-reconnect failed: ${result.error ?? 'unknown error'}`,
});
}
conn = getAcpConnection(body.acpAgentId as string);
}
if (!conn) {
throw createError({ statusCode: 400, message: 'ACP agent not connected' });
}
// Create ACP session. ACP agents need the OpenPencil MCP server to do
// anything useful (without it they just call Terminal/Skill tools that
// don't work here). Require it to be running — the user starts it from
// the MCP settings tab.
// NOTE: claude-agent-acp expects `type: 'http' | 'sse'` (not `transport`).
const mcpStatus = getMcpServerStatus();
if (!mcpStatus.running || !mcpStatus.port) {
throw createError({
statusCode: 400,
message:
'MCP server is not running. Open Settings → MCP and click "Start" to enable ACP agents to access OpenPencil design tools.',
});
}
const mcpServers = [
{
name: 'openpencil',
type: 'http' as const,
url: `http://127.0.0.1:${mcpStatus.port}/mcp`,
headers: [] as Array<{ name: string; value: string }>,
},
];
console.log(
`[acp] newSession mcpServers=${JSON.stringify(mcpServers)} (mcpStatus: running=${mcpStatus.running}, port=${mcpStatus.port})`,
);
// Override the agent's default system prompt (via _meta) to prevent it
// from using the openpencil-skill (which is designed for CLI scenarios
// where the agent runs `op` commands in terminals). Inside OpenPencil,
// the agent should use MCP tools directly.
const acpSystemPrompt = [
'You are an AI design assistant integrated inside the OpenPencil vector design tool.',
'The user sees a live canvas; your job is to produce polished, visually refined UI designs on it.',
'You have direct access to OpenPencil\'s document via the "openpencil" MCP server.',
'',
'## Tool Usage Rules',
'- NEVER use Bash/Terminal to run `op` CLI commands. The CLI is not available here.',
'- NEVER use the openpencil-skill or Skill tool. They are for a different context.',
'- DO use the `mcp__openpencil__*` tools to operate on the canvas.',
'- After finishing, provide a brief one-sentence summary of what was done.',
'',
'## REQUIRED Workflow for Creating New Designs',
'Always follow this three-phase pipeline (it produces higher quality than ad-hoc insert calls):',
'',
"1. **Load the design guide (ONCE)**: Call `get_design_prompt` to receive OpenPencil's design principles, node schema details, role system, color/typography tokens, and layout patterns. Read it carefully — it defines the canonical shapes and defaults.",
'2. **Build skeleton**: Call `design_skeleton` with a high-level description. This creates the structural frames (sections, layout containers) with correct auto-layout.',
'3. **Fill content**: Call `design_content` once per section from step 2, adding the concrete children (buttons, inputs, text, icons).',
'4. **Refine**: Call `design_refine` on the root to apply final polish (consistent spacing, role-based styling). Do this before you summarize.',
'',
'Only fall back to `batch_design` or `insert_node` when the user explicitly asks for small/surgical edits rather than a new page.',
'',
'## Design Quality Bar',
'- Avoid crowded output. Prefer fewer, stronger modules with visible negative space over filling every available area.',
'- For mobile screens, use one App Content wrapper for the main body. The wrapper owns horizontal padding (16-20px) and vertical gap (20-24px); inner sections should not each add competing gutters.',
'- Keep one primary job per screen. Above the fold, show context/title, the primary action or search, and at most 2-3 supporting modules.',
'- Use a clear type rhythm: one title size, one section heading size, and one body/caption size. Avoid many near-identical bold text sizes.',
'- Reuse one card radius, one card padding, and one shadow treatment within a screen.',
'- Use at most two saturated colors. Let hierarchy come from spacing, contrast, and content scale.',
'',
'## Modifying Existing Designs',
'- Call `snapshot_layout` first to see the current tree.',
'- Use `update_node` for property changes, `move_node` for reparenting, `delete_node` to remove.',
'- Prefer one `batch_design` over many individual calls when making multiple related changes.',
'',
'## Canonical Node Shapes (IMPORTANT)',
'The canvas will render nothing useful if you use the wrong `type` or shape. Use these:',
'',
'- **Frame** (container with layout): `{"type": "frame", "name": "X", "width": 375, "height": 812, "layout": "vertical", "gap": 16, "padding": [24, 24, 24, 24], "fill": [{"type": "solid", "color": "#FFFFFF"}], "children": [...]}`',
'- **Text** (field is `content` NOT `text`): `{"type": "text", "name": "Title", "content": "Welcome", "fontSize": 24, "fontWeight": 700, "fill": [{"type": "solid", "color": "#111827"}]}`',
'- **Icon** (use `icon_font` NOT `icon`, field is `iconFontName` NOT `iconName`): `{"type": "icon_font", "name": "Lock Icon", "iconFontName": "lock", "width": 20, "height": 20, "fill": [{"type": "solid", "color": "#6B7280"}]}`. Common iconFontName values (Lucide): `mail`, `lock`, `eye`, `eye-off`, `chrome`, `apple`, `message-circle`, `x`, `arrow-right`, `search`, `heart`, `star`, `check`, `plus`, `bell`, `home`, `user`, `settings`.',
'- **Rectangle**: `{"type": "rectangle", "width": 100, "height": 100, "cornerRadius": 8, "fill": [{"type": "solid", "color": "#3B82F6"}]}`',
'- **Button** (frame + text child): use `"role": "cta-button"` on the frame so role resolution applies standard button styling.',
'',
'## STRICT JSON Rules',
'When emitting node JSON inside tool arguments, produce strictly valid JSON:',
'- Every property MUST have BOTH a key and value. NEVER emit `": 50` or `: 50` with no key.',
'- Every key MUST be a double-quoted non-empty string.',
'- `fill` is ALWAYS an array: `"fill": [{"type": "solid", "color": "#hex"}]`.',
'- `stroke` is `{"thickness": 1, "fill": [{"type": "solid", "color": "#hex"}]}`. NEVER `{"thickness": 1, "color": "#hex"}`.',
'- NO trailing commas, NO comments, use straight `"` not smart quotes.',
'- Layout on frames: `"layout": "vertical" | "horizontal" | "none"`, `"gap": number`, `"padding": [top, right, bottom, left]`, `"alignItems": "start" | "center" | "end"`, `"justifyContent": "start" | "center" | "end" | "space-between"`.',
'- Width/height: number OR `"fill_container"` OR `"fit_content"`.',
'- Before calling the tool, mentally verify the JSON is valid. Every key has a value; every value has a key.',
].join('\n');
const { sessionId: acpSessionId } = await conn.connection.newSession({
cwd: process.cwd(),
mcpServers,
_meta: { systemPrompt: acpSystemPrompt },
} as Parameters<typeof conn.connection.newSession>[0]);
const clientSessionId = body.sessionId as string;
agentSessions.set(
clientSessionId,
createAcpSession({
acpSessionId,
acpAgentId: body.acpAgentId as string,
connection: conn.connection,
}),
);
// Build prompt from last user message
const lastMsg = ((body.messages as any[]) ?? []).at(-1);
const promptText =
typeof lastMsg?.content === 'string'
? lastMsg.content
: JSON.stringify(lastMsg?.content ?? '');
// Wire session/update notifications into SSE stream
const updateTarget = new EventTarget();
conn.sessionUpdateEmitter = updateTarget;
const encoder = new TextEncoder();
let streamClosed = false;
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (chunk: Uint8Array) => {
if (streamClosed) return;
try {
controller.enqueue(chunk);
} catch {
// Controller may have closed mid-notification (e.g. client disconnect,
// idle timeout). Mark closed and stop enqueuing to avoid noise.
streamClosed = true;
}
};
const onUpdate = (e: Event) => {
const notification = (e as CustomEvent).detail;
const sse = acpUpdateToSSE(notification);
if (sse) safeEnqueue(encoder.encode(sse));
};
updateTarget.addEventListener('update', onUpdate);
// Keep-alive: prevent Bun's 10s idle timeout from killing the stream
// during long MCP tool calls (e.g. snapshot_layout → insert_node chain).
const keepAlive = startSSEKeepAlive(
() => safeEnqueue(encoder.encode(`: keepalive\n\n`)),
5000,
);
try {
console.log(`[acp] prompt() start for ${acpSessionId}`);
const promptResult = await conn.connection.prompt({
sessionId: acpSessionId,
prompt: [{ type: 'text', text: promptText }],
});
console.log(
`[acp] prompt() returned, stopReason=${(promptResult as { stopReason?: string })?.stopReason ?? 'unknown'}, streamClosed=${streamClosed}`,
);
safeEnqueue(
encoder.encode(
`event: done\ndata: ${JSON.stringify({ type: 'done', totalTurns: 1 })}\n\n`,
),
);
} catch (err) {
console.error(`[acp] prompt() threw:`, err);
safeEnqueue(
encoder.encode(
`event: error\ndata: ${JSON.stringify({
type: 'error',
message: `ACP error: ${err instanceof Error ? err.message : String(err)}`,
fatal: true,
})}\n\n`,
),
);
} finally {
console.log(`[acp] prompt() finally, closing stream`);
clearInterval(keepAlive);
updateTarget.removeEventListener('update', onUpdate);
conn.sessionUpdateEmitter = null;
agentSessions.delete(clientSessionId);
if (!streamClosed) {
streamClosed = true;
try {
controller.close();
} catch {
/* already closed */
}
}
}
},
});
setResponseHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
return new Response(stream);
}
if (
!body?.sessionId ||
!body.messages ||
!body.systemPrompt ||
!body.providerType ||
!body.apiKey ||
!body.model
) {
throw createError({
statusCode: 400,
message:
'Missing required fields: sessionId, messages, systemPrompt, providerType, apiKey, model',
});
}
const normalizedBaseURL = normalizeOptionalBaseURL(body.baseURL);
if (body.providerType === 'openai-compat' && !normalizedBaseURL) {
throw createError({
statusCode: 400,
message: 'OpenAI-compatible provider requires baseURL',
});
}
// Diagnostic logging for the cross-provider empty-response bug.
// When a provider returns a 200 OK + message_start + immediate
// stream close (0 content blocks), the failure is silent at the
// provider edge. This log captures the ACTUAL upstream shape —
// NOT the raw body fields — because the server applies several
// transformations between reading the body and issuing the
// upstream request:
//
// 1. teamMode && concurrency >= 2 appends
// `buildTeamCapabilitiesPrompt(concurrency)` to systemPrompt
// 2. teamMode auto-registers the `spawn_member` tool on top of
// whatever the client sent in `toolDefs`
// 3. Prior messages are filtered to `role in {user, assistant}
// && typeof content === 'string'` before being seeded; the
// LAST message becomes the new-turn prompt
// 4. `registerToolSchema` only sends `parameters` (with $schema
// stripped), not the full `ToolDef`, so tool-schema size is
// computed from `parameters` alone
//
// This block mirrors all four transformations so the logged
// numbers match what the native agent runtime actually sends to
// the provider edge.
//
// Gated by a hard-coded constant so flipping it off is one line.
const OUTER_AGENT_LOG_ENABLED = true;
if (OUTER_AGENT_LOG_ENABLED) {
const concurrency = body.concurrency ?? 1;
// (1) Effective system prompt — mirrors teamSystemPrompt logic below.
const effectiveSystemPrompt =
body.teamMode && concurrency >= 2
? (body.systemPrompt ?? '') + buildTeamCapabilitiesPrompt(concurrency)
: (body.systemPrompt ?? '');
// (3) Seeded prior messages — same filter as seedMessages /
// seedTeamMessages below.
const allMessages = body.messages ?? [];
const newPromptRaw = allMessages[allMessages.length - 1]?.content;
const newPromptChars = typeof newPromptRaw === 'string' ? newPromptRaw.length : 0;
const priorMessages = allMessages
.slice(0, -1)
.filter(
(m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string',
);
const priorMessageChars = priorMessages.reduce(
(sum, m) => sum + (m.content as string).length,
0,
);
// (2, 4) Effective tool count + on-wire schema bytes.
//
// On-wire tool list in team mode includes up to THREE classes
// of additions on top of the client-supplied body.toolDefs:
//
// a) `spawn_member` — registered ONLY when body.teamMode===true
// via `registerToolSchema(tools, 'spawn_member', SPAWN_MEMBER_SCHEMA)`.
//
// b) `delegate` — registered by `teamRegisterDelegate(team)`,
// which runs whenever `body.teamMode || normalizedMembers.length`
// (i.e. any team-mode branch). This is a NATIVE runtime-side
// registration inside `team.registerDelegateTool()` in
// packages/agent-native/src/team.zig. The schema it registers
// has a fixed shape: `{type:"object", properties:{member_id,
// task}, required:[member_id,task]}` — 159 bytes as the
// `input_schema` parameters blob. I was missing this
// entirely in the previous log.
//
// c) Member-specific tools registered via addTeamMember() when
// normalizedMembers.length > 0. Each member has its OWN
// tool registry and those tools are NOT on the leader's
// on-wire payload, so they don't count toward the leader
// request shape we log here.
//
// Tool schemas are serialized as JSON.stringify(parameters) with
// `$schema` stripped — see registerToolSchema call below. We
// mirror that transform here so the log matches the bytes the
// native runtime actually pushes over the wire.
const toolDefsChars = (body.toolDefs ?? []).reduce((sum, t) => {
const params = t.parameters ? { ...(t.parameters as Record<string, unknown>) } : {};
delete (params as Record<string, unknown>).$schema;
return sum + JSON.stringify(params).length;
}, 0);
// Delegate schema text, verbatim from team.zig::registerDelegateTool.
// Hard-coded here rather than imported because it lives inside a
// Zig function body and isn't exported. Keep in sync if the Zig
// side is ever edited (the unit test coverage in team.zig catches
// drift on that side; this side is a diagnostic log only).
const DELEGATE_INPUT_SCHEMA =
'{"type":"object","properties":{"member_id":{"type":"string","description":"ID of the team member to delegate to"},"task":{"type":"string","description":"Task description for the member"}},"required":["member_id","task"]}';
// The team-mode branch below is gated on `body.teamMode ||
// normalizedMembers.length`. `normalizedMembers` is derived from
// `body.members` 1:1 (same length, just adds a normalized
// baseURL field), so the raw count matches. normalizedMembers
// itself is computed AFTER this log block, so we use body.members
// directly to predict whether the team branch will be taken.
const teamModeBranch = !!(body.teamMode || (body.members ?? []).length);
let effectiveToolCount = (body.toolDefs ?? []).length;
let effectiveToolChars = toolDefsChars;
if (body.teamMode) {
effectiveToolCount += 1;
effectiveToolChars += SPAWN_MEMBER_SCHEMA.length;
}
if (teamModeBranch) {
effectiveToolCount += 1;
effectiveToolChars += DELEGATE_INPUT_SCHEMA.length;
}
console.log(
`[agent-request] provider=${body.providerType} model=${body.model} teamMode=${!!body.teamMode} concurrency=${concurrency} ` +
`effectiveSystemPrompt=${effectiveSystemPrompt.length} ` +
`newPromptChars=${newPromptChars} ` +
`seededPriorMessages=${priorMessages.length}(totalChars=${priorMessageChars}) ` +
`effectiveTools=${effectiveToolCount}(onWireSchemaChars=${effectiveToolChars}) ` +
`members=${(body.members ?? []).length} maxOutputTokens=${body.maxOutputTokens ?? 'default'}`,
);
}
// Validate all member baseURLs upfront before allocating any native handles
const normalizedMembers = (body.members ?? []).map((m) => {
try {
return { ...m, normalizedBaseURL: normalizeMemberBaseURL(m.id, m.providerType, m.baseURL) };
} catch (err: any) {
throw createError({ statusCode: 400, message: err.message });
}
});
// providerType is narrowed: 'acp' returned early above
const provider = createProviderHandle(
body.providerType as 'anthropic' | 'openai-compat',
body.apiKey,
body.model,
normalizedBaseURL,
body.maxContextTokens,
);
const tools = createToolRegistry();
for (const def of body.toolDefs ?? []) {
const params = def.parameters ? { ...def.parameters } : { type: 'object' };
delete (params as any).$schema;
registerToolSchema(tools, def.name, JSON.stringify(params));
}
const prompt = body.messages[body.messages.length - 1]?.content ?? '';
let session: AgentSession;
if (body.teamMode || normalizedMembers.length) {
const concurrency = body.concurrency ?? 1;
console.info(`[agent] creating team (teamMode=${!!body.teamMode}, concurrency=${concurrency})`);
// Append team capabilities to system prompt when teamMode
const teamSystemPrompt =
body.teamMode && concurrency >= 2
? body.systemPrompt + buildTeamCapabilitiesPrompt(concurrency)
: body.systemPrompt;
const team = createTeam(
provider,
tools,
teamSystemPrompt,
body.maxTurns ?? 20,
body.maxOutputTokens,
);
const memberHandles: Array<{
provider: ReturnType<typeof createProviderHandle>;
tools: ReturnType<typeof createToolRegistry>;
}> = [];
// Legacy path: pre-configured members from client
if (normalizedMembers.length) {
for (const m of normalizedMembers) {
const memberProvider = createProviderHandle(
m.providerType,
m.apiKey,
m.model,
m.normalizedBaseURL,
);
const memberTools = createToolRegistry();
addTeamMember(team, m.id, memberProvider, memberTools, m.systemPrompt ?? '', 20);
memberHandles.push({ provider: memberProvider, tools: memberTools });
}
}
// Register spawn_member + delegate tools when teamMode
if (body.teamMode) {
registerToolSchema(tools, 'spawn_member', SPAWN_MEMBER_SCHEMA);
}
teamRegisterDelegate(team);
// Seed prior conversation history onto the lead engine
const priorMessages = body.messages
.slice(0, -1)
.filter(
(m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string',
);
if (priorMessages.length > 0) {
seedTeamMessages(team, JSON.stringify(priorMessages));
}
session = createSession({
team,
provider,
tools,
memberHandles,
createdAt: Date.now(),
lastActivity: Date.now(),
});
} else {
// Single engine mode
const engine = createQueryEngine({
provider,
tools,
systemPrompt: body.systemPrompt,
maxTurns: body.maxTurns ?? 20,
maxOutputTokens: body.maxOutputTokens,
cwd: process.cwd(),
});
// Seed conversation history
const priorMessages = body.messages
.slice(0, -1)
.filter(
(m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string',
);
if (priorMessages.length > 0) {
seedMessages(engine, JSON.stringify(priorMessages));
}
session = createSession({
engine,
provider,
tools,
createdAt: Date.now(),
lastActivity: Date.now(),
});
}
// Register session for tool result callbacks and abort
agentSessions.set(body.sessionId, session);
setResponseHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Keep pings active for the full orchestration. Delegated tool calls can
// legitimately stall visible output for >10s while the model waits.
const pingTimer = startSSEKeepAlive(() => {
controller.enqueue(encoder.encode(': ping\n\n'));
touchSession(session);
}, 5_000);
let iter;
try {
iter = session.team
? await runTeam(session.team, prompt)
: await submitMessage(session.engine!, prompt);
session.iter = iter;
let raw: string | null;
let eventCount = 0;
while ((raw = await nextEvent(iter)) !== null) {
eventCount++;
session.lastActivity = Date.now();
// Log first 5 events and any tool_use/result events for diagnostics
if (eventCount <= 5 || raw.includes('tool_use') || raw.includes('"result"')) {
const preview = raw.length > 200 ? raw.substring(0, 200) + '...' : raw;
console.info(`[agent] event #${eventCount}: ${preview}`);
}
if (session.team) {
try {
const evt = JSON.parse(raw);
// ── spawn_member intercept ──
if (evt.tool_use && evt.tool_use.name === 'spawn_member') {
const toolUseId = evt.tool_use.id;
const inputData =
typeof evt.tool_use.input === 'string'
? JSON.parse(evt.tool_use.input)
: evt.tool_use.input;
const memberId: string = inputData?.id;
const role: string = inputData?.role;
const memberModel: string | undefined = inputData?.model;
if (!memberId || !role || !ROLE_TOOL_PRESETS[role]) {
resolveTeamToolResult(
session.team,
toolUseId,
JSON.stringify({
success: false,
error: `Invalid spawn_member args: id=${memberId}, role=${role}`,
}),
);
continue;
}
// Check duplicate
if (session.memberRoles.has(memberId)) {
resolveTeamToolResult(
session.team,
toolUseId,
JSON.stringify({
success: false,
error: `Member "${memberId}" already exists`,
}),
);
continue;
}
// Create provider (use member model or lead's)
const mProvider = createProviderHandle(
body.providerType as 'anthropic' | 'openai-compat',
body.apiKey,
memberModel ?? body.model,
normalizedBaseURL,
body.maxContextTokens,
);
// Create tool registry with role preset
const mTools = createToolRegistry();
const allDefs = getAllToolDefs();
const presetNames = ROLE_TOOL_PRESETS[role];
for (const name of presetNames) {
const def = allDefs.find((d) => d.name === name);
if (def) {
const params = def.parameters ? { ...def.parameters } : { type: 'object' };
delete (params as any).$schema;
registerToolSchema(mTools, name, JSON.stringify(params));
}
}
// Build member system prompt with role skills
const memberPrompt = buildMemberSystemPrompt(
role,
body.designMdContent,
body.hasVariables,
);
addTeamMember(session.team, memberId, mProvider, mTools, memberPrompt, 20);
if (!session.memberHandles) session.memberHandles = [];
session.memberHandles.push({ provider: mProvider, tools: mTools });
session.memberRoles.set(memberId, role);
resolveTeamToolResult(
session.team,
toolUseId,
JSON.stringify({
success: true,
member_id: memberId,
role,
tools: presetNames,
}),
);
continue;
}
// ── delegate intercept (enhanced with member tool routing) ──
if (evt.tool_use && evt.tool_use.name === 'delegate') {
const toolUseId = evt.tool_use.id;
let memberIdRaw: string | undefined;
let taskRaw: string | undefined;
const inputData = evt.tool_use.input;
if (typeof inputData === 'string') {
try {
const parsed = JSON.parse(inputData);
memberIdRaw = parsed.member_id;
taskRaw = parsed.task;
} catch {
/* fallback below */
}
} else if (inputData && typeof inputData === 'object') {
memberIdRaw = inputData.member_id;
taskRaw = inputData.task;
}
if (memberIdRaw && taskRaw) {
// Fire-and-forget: run member in parallel. The Zig engine blocks in
// waiting_for_external_tools until ALL delegate results are resolved.
// By not awaiting, multiple delegates run concurrently.
runDelegateMember(
session,
body,
controller,
encoder,
toolUseId,
memberIdRaw,
taskRaw,
).catch((err) => {
console.error(`[agent] delegate ${memberIdRaw} failed:`, err);
try {
resolveTeamToolResult(
session.team!,
toolUseId,
JSON.stringify({ result: `Error: ${err?.message ?? String(err)}` }),
);
} catch {
/* ignore */
}
});
continue;
}
}
} catch {
/* not JSON or not intercepted — fall through to normal forwarding */
}
}
if (!session.team) {
try {
const evt = JSON.parse(raw);
if (evt.tool_use?.id && evt.tool_use?.name) {
const toolUseId = evt.tool_use.id as string;
const toolName = evt.tool_use.name as string;
session.toolNames.set(toolUseId, toolName);
const syntheticResult = shouldShortCircuitPlanLayout(
session,
toolName,
evt.tool_use.input,
);
if (syntheticResult && session.engine) {
resolveToolResult(session.engine, toolUseId, JSON.stringify(syntheticResult));
session.toolNames.delete(toolUseId);
controller.enqueue(
encoder.encode(
`event: tool_result\ndata: ${JSON.stringify({
type: 'tool_result',
id: toolUseId,
name: toolName,
result: syntheticResult,
})}\n\n`,
),
);
continue;
}
}
} catch {
/* ignore parse errors and forward raw event */
}
}
const sse = zigEventToSSE(raw);
if (sse) controller.enqueue(encoder.encode(sse));
}
console.info(`[agent] stream ended after ${eventCount} events`);
} catch (err: any) {
console.error(`[agent] stream error:`, err?.message ?? String(err));
try {
controller.enqueue(
encoder.encode(
`event: error\ndata: ${JSON.stringify({ type: 'error', message: err?.message ?? String(err), fatal: true })}\n\n`,
),
);
} catch {
/* ignore */
}
} finally {
clearInterval(pingTimer);
agentSessions.delete(body.sessionId);
cleanup(session);
try {
controller.close();
} catch {
/* ignore */
}
}
},
});
return new Response(stream);
});