Files
makelore/electron/coding-runtime/pi/event-projector.ts

759 lines
24 KiB
TypeScript

import type {
ConversationContentBlock,
ConversationMessageNode,
ConversationPatch,
ConversationSnapshot,
ConversationToolNode,
PublicUsage,
} from '../contracts';
import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details';
import type { PiRpcEvent } from './rpc-client';
import { subagentDetailsOfResult } from '../subagent-protocol';
import {
isProductToolName,
productToolDetailsOfResult,
} from '../product-tool-protocol';
export interface PiEventProjectorOptions {
createId(): string;
now?: () => number;
projectImage?(image: PiImageProjectionInput): Promise<PiProjectedAttachment>;
}
export interface PiImageProjectionInput {
conversationId: string;
data: string;
mime: string;
source: 'live' | 'session';
}
export interface PiProjectedAttachment {
attachmentId: string;
mime: string;
}
export interface PiProjectionDiagnostic {
eventType: string;
reason: 'unsupported-event';
}
function retryFailure(message: string) {
return {
code: 'CODING_RUNTIME_START_FAILED' as const,
message,
recoverable: true,
};
}
function providerFailure(message: unknown) {
if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) {
return {
code: 'CODING_PROVIDER_AUTH_REQUIRED' as const,
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
recoverable: true,
};
}
return retryFailure('模型服务请求失败,请稍后重试。');
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function usageOf(value: unknown): PublicUsage | undefined {
const usage = asRecord(value);
if (!usage
|| typeof usage.input !== 'number'
|| typeof usage.output !== 'number') return undefined;
return {
inputTokens: usage.input,
outputTokens: usage.output,
...(typeof usage.cacheRead === 'number' ? { cacheReadTokens: usage.cacheRead } : {}),
...(typeof usage.cacheWrite === 'number' ? { cacheWriteTokens: usage.cacheWrite } : {}),
};
}
function stopReasonOf(value: unknown): ConversationMessageNode['stopReason'] | undefined {
if (value === 'toolUse') return 'tool-use';
if (value === 'stop' || value === 'length' || value === 'error' || value === 'aborted') {
return value;
}
return undefined;
}
function currentAssistant(snapshot: ConversationSnapshot): ConversationMessageNode | undefined {
return snapshot.nodes.findLast(
(node): node is ConversationMessageNode => node.kind === 'message'
&& node.role === 'assistant'
&& node.status === 'streaming',
);
}
function outputBlocks(toolId: string, value: unknown): ConversationContentBlock[] {
const result = asRecord(value);
if (!result || !Array.isArray(result.content)) return [];
return result.content.flatMap((item, contentIndex) => {
const block = asRecord(item);
if (!block || block.type !== 'text' || typeof block.text !== 'string') return [];
return [{
kind: 'text' as const,
id: `${toolId}:output:${contentIndex}`,
text: block.text,
status: 'complete' as const,
}];
});
}
function projectToolResult(
snapshot: ConversationSnapshot,
tool: ConversationToolNode,
status: ConversationToolNode['status'],
value: unknown,
): ConversationPatch[] {
const subagentDetails = subagentDetailsOfResult(value);
const details = subagentDetails ?? productToolDetailsOfResult(value);
const result = asRecord(value);
const unknownProductDetails = (tool.toolName === 'subagent' || isProductToolName(tool.toolName))
&& result?.details !== undefined
&& !details;
const output = unknownProductDetails
? [{
kind: 'text' as const,
id: `${tool.id}:output:unavailable`,
text: tool.toolName === 'subagent'
? 'Subagent details are unavailable for this version.'
: 'Tool details are unavailable for this version.',
status: 'complete' as const,
}]
: outputBlocks(tool.id, value);
const patches: ConversationPatch[] = [{
op: 'tool.upsert',
node: {
...tool,
status,
output,
...(details ? { details } : {}),
},
}];
const runId = snapshot.run.runId;
if (subagentDetails && runId) {
patches.push({
op: 'subagent.upsert',
node: {
kind: 'subagent',
id: `subagent:${subagentDetails.dispatchId}`,
runId,
details: subagentDetails,
},
});
}
return patches;
}
function currentTool(snapshot: ConversationSnapshot, toolCallId: string): ConversationToolNode | undefined {
return snapshot.nodes.find(
(node): node is ConversationToolNode => node.kind === 'tool'
&& node.toolCallId === toolCallId,
);
}
async function liveContentBlocks(
messageId: string,
content: unknown,
conversationId: string,
projectImage: PiEventProjectorOptions['projectImage'],
): Promise<ConversationContentBlock[]> {
const values = typeof content === 'string'
? [{ type: 'text', text: content }]
: Array.isArray(content) ? content : [];
const blocks: ConversationContentBlock[] = [];
for (let contentIndex = 0; contentIndex < values.length; contentIndex += 1) {
const block = asRecord(values[contentIndex]);
if (block?.type === 'text' && typeof block.text === 'string') {
blocks.push({
kind: 'text',
id: `${messageId}:content:${contentIndex}`,
text: block.text,
status: 'complete',
});
} else if (block?.type === 'thinking' && typeof block.thinking === 'string') {
blocks.push({
kind: 'thinking',
id: `${messageId}:content:${contentIndex}`,
text: block.thinking,
status: 'complete',
});
} else if (block?.type === 'image'
&& typeof block.data === 'string'
&& typeof block.mimeType === 'string'
&& projectImage) {
const attachment = await projectImage({
conversationId,
data: block.data,
mime: block.mimeType,
source: 'live',
});
blocks.push({
kind: 'image',
id: `${messageId}:content:${contentIndex}`,
attachmentId: attachment.attachmentId,
mime: attachment.mime,
});
}
}
return blocks;
}
export class PiEventProjector {
private readonly createId: () => string;
private readonly now: () => number;
private readonly projectImage: PiEventProjectorOptions['projectImage'];
private assistantMessageId: string | null = null;
private readonly toolDrafts = new Map<number, { id: string; argumentsText: string }>();
private compactionId: string | null = null;
private summarizationRetryBaseRun: ConversationSnapshot['run'] | null = null;
private readonly diagnostics: PiProjectionDiagnostic[] = [];
constructor(options: PiEventProjectorOptions) {
this.createId = options.createId;
this.now = options.now ?? Date.now;
this.projectImage = options.projectImage;
}
getDiagnostics(): PiProjectionDiagnostic[] {
return structuredClone(this.diagnostics);
}
async project(
snapshot: ConversationSnapshot,
event: PiRpcEvent,
): Promise<ConversationPatch[]> {
if (event.type === 'agent_start') {
return [{
op: 'run.state',
run: {
...snapshot.run,
status: 'running',
runId: snapshot.run.runId ?? this.createId(),
startedAt: snapshot.run.startedAt ?? this.now(),
},
}];
}
if (event.type === 'turn_start' || event.type === 'turn_end') {
const runId = snapshot.run.runId ?? this.createId();
return [{
op: 'boundary.upsert',
node: {
kind: 'boundary',
id: this.createId(),
runId,
boundary: event.type === 'turn_start' ? 'turn-start' : 'turn-end',
},
}];
}
if (event.type === 'auto_retry_start'
&& typeof event.attempt === 'number'
&& typeof event.delayMs === 'number') {
const runId = snapshot.run.runId ?? this.createId();
return [
{
op: 'boundary.upsert',
node: {
kind: 'boundary',
id: this.createId(),
runId,
boundary: 'retry',
attempt: event.attempt,
delayMs: event.delayMs,
},
},
{
op: 'run.state',
run: {
...snapshot.run,
status: 'retrying',
runId,
retry: { attempt: event.attempt, delayMs: event.delayMs },
},
},
];
}
if (event.type === 'auto_retry_end' && typeof event.success === 'boolean') {
const { retry: _retry, ...run } = snapshot.run;
if (event.success) {
return [{ op: 'run.state', run: { ...run, status: 'running' } }];
}
return [{
op: 'run.state',
run: {
...run,
status: 'error',
terminalReason: 'failed',
error: providerFailure(event.finalError),
},
}];
}
if (event.type === 'summarization_retry_scheduled'
&& typeof event.attempt === 'number'
&& typeof event.delayMs === 'number') {
const runId = snapshot.run.runId ?? this.createId();
this.summarizationRetryBaseRun ??= { ...snapshot.run, runId };
return [
{
op: 'boundary.upsert',
node: {
kind: 'boundary',
id: this.createId(),
runId,
boundary: 'retry',
attempt: event.attempt,
delayMs: event.delayMs,
},
},
{
op: 'run.state',
run: {
...snapshot.run,
status: 'retrying',
runId,
retry: { attempt: event.attempt, delayMs: event.delayMs },
},
},
];
}
if (event.type === 'summarization_retry_attempt_start'
&& (event.source === 'branchSummary' || event.source === 'compaction')) {
const base = this.summarizationRetryBaseRun ?? snapshot.run;
const { retry: _retry, ...run } = base;
return [
...(event.source === 'compaction'
? [{
op: 'context.replace' as const,
context: { ...snapshot.context, compaction: 'running' as const },
}]
: []),
{
op: 'run.state',
run: {
...run,
status: event.source === 'compaction' ? 'compacting' : run.status,
},
},
];
}
if (event.type === 'summarization_retry_finished') {
const base = this.summarizationRetryBaseRun ?? snapshot.run;
this.summarizationRetryBaseRun = null;
const { retry: _retry, ...run } = base;
return [{ op: 'run.state', run }];
}
if (event.type === 'compaction_start') {
const runId = snapshot.run.runId ?? this.createId();
const id = this.createId();
this.compactionId = id;
return [
{
op: 'compaction.upsert',
node: {
kind: 'compaction',
id,
runId,
source: event.reason === 'manual' ? 'manual' : 'automatic',
status: 'running',
willRetry: false,
},
},
{
op: 'context.replace',
context: { ...snapshot.context, compaction: 'running', lastCompactionId: id },
},
{ op: 'run.state', run: { ...snapshot.run, status: 'compacting', runId } },
];
}
if (event.type === 'compaction_end') {
const id = this.compactionId ?? this.createId();
const runId = snapshot.run.runId ?? this.createId();
this.compactionId = null;
const compactionFailed = typeof event.errorMessage === 'string';
const { retry: _retry, ...run } = snapshot.run;
return [
{
op: 'compaction.upsert',
node: {
kind: 'compaction',
id,
runId,
source: event.reason === 'manual' ? 'manual' : 'automatic',
status: event.aborted === true || typeof event.errorMessage === 'string'
? 'error'
: 'complete',
willRetry: event.willRetry === true,
},
},
{
op: 'context.replace',
context: { ...snapshot.context, compaction: 'idle', lastCompactionId: id },
},
{
op: 'run.state',
run: {
...run,
status: event.willRetry === true
? 'running'
: compactionFailed ? 'error' : snapshot.run.status,
runId,
...(compactionFailed
? {
terminalReason: 'failed' as const,
error: retryFailure('The local Agent summarization failed'),
}
: {}),
},
},
];
}
if (event.type === 'queue_update'
&& Array.isArray(event.steering)
&& Array.isArray(event.followUp)) {
const existing = [...snapshot.queue.items];
const item = (mode: 'steer' | 'follow-up', text: string) => {
const matchIndex = existing.findIndex((candidate) => candidate.mode === mode
&& candidate.text === text);
if (matchIndex >= 0) return existing.splice(matchIndex, 1)[0]!;
const id = this.createId();
return { id, clientRequestId: id, mode, text, attachmentIds: [] };
};
return [{
op: 'queue.replace',
queue: {
items: [
...event.steering.filter((value): value is string => typeof value === 'string')
.map((text) => item('steer', text)),
...event.followUp.filter((value): value is string => typeof value === 'string')
.map((text) => item('follow-up', text)),
],
},
}];
}
if (event.type === 'agent_settled') {
const terminalReason = snapshot.run.status === 'aborting'
? 'aborted' as const
: snapshot.run.status === 'error' || snapshot.run.terminalReason === 'failed'
? 'failed' as const
: 'completed' as const;
this.summarizationRetryBaseRun = null;
return [
{
op: 'run.state',
run: {
status: 'idle',
...(snapshot.run.runId ? { runId: snapshot.run.runId } : {}),
...(snapshot.run.mode ? { mode: snapshot.run.mode } : {}),
...(snapshot.run.startedAt !== undefined ? { startedAt: snapshot.run.startedAt } : {}),
settledAt: this.now(),
terminalReason,
...(terminalReason === 'failed' && snapshot.run.error
? { error: snapshot.run.error }
: {}),
},
},
{ op: 'queue.replace', queue: { items: [] } },
];
}
if (event.type === 'agent_end' && event.willRetry === false && Array.isArray(event.messages)) {
const assistant = [...event.messages]
.reverse()
.map(asRecord)
.find((message) => message?.role === 'assistant');
if (assistant?.stopReason === 'error') {
return [{
op: 'run.state',
run: {
...snapshot.run,
status: 'error',
terminalReason: 'failed',
error: providerFailure(assistant.errorMessage),
},
}];
}
return [];
}
if (event.type === 'extension_ui_request'
&& typeof event.id === 'string'
&& typeof event.method === 'string'
&& typeof event.title === 'string'
&& ['select', 'confirm', 'input', 'editor'].includes(event.method)) {
const kind = event.method as 'select' | 'confirm' | 'input' | 'editor';
return [{
op: 'interaction.upsert',
interaction: {
id: event.id,
conversationId: snapshot.conversation.id,
runId: snapshot.run.runId ?? `interaction:${event.id}`,
kind,
title: event.title,
...(typeof event.message === 'string' ? { message: event.message } : {}),
...(kind === 'select' && Array.isArray(event.options)
? {
options: event.options
.filter((option): option is string => typeof option === 'string')
.map((label, index) => ({
id: `${event.id}:option:${index}`,
label,
})),
}
: {}),
status: 'pending',
},
}];
}
if (event.type === 'message_start') {
const message = asRecord(event.message);
if (message?.role === 'user') {
const optimistic = snapshot.nodes.findLast(
(node): node is ConversationMessageNode => node.kind === 'message'
&& node.role === 'user'
&& node.status === 'optimistic',
);
const id = optimistic?.id ?? this.createId();
return [{
op: 'message.upsert',
node: {
kind: 'message',
id,
...(optimistic?.clientRequestId
? { clientRequestId: optimistic.clientRequestId }
: {}),
role: 'user',
status: 'complete',
blocks: await liveContentBlocks(
id,
message.content,
snapshot.conversation.id,
this.projectImage,
),
},
}];
}
if (message?.role !== 'assistant') return [];
const id = this.createId();
this.assistantMessageId = id;
return [{
op: 'message.upsert',
node: {
kind: 'message',
id,
role: 'assistant',
status: 'streaming',
blocks: [],
...(usageOf(message.usage) ? { usage: usageOf(message.usage) } : {}),
},
}];
}
if (event.type === 'message_update') {
const update = asRecord(event.assistantMessageEvent);
const message = currentAssistant(snapshot);
if (!update || !message || typeof update.contentIndex !== 'number') return [];
const blockId = `${message.id}:content:${update.contentIndex}`;
if (update.type === 'text_start') {
return [{
op: 'message.upsert',
node: {
...message,
blocks: [
...message.blocks.filter((block) => block.id !== blockId),
{ kind: 'text', id: blockId, text: '', status: 'streaming' },
],
...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}),
},
}];
}
if (update.type === 'text_delta' && typeof update.delta === 'string') {
return [{
op: 'message.block-delta',
messageId: message.id,
blockId,
delta: update.delta,
}];
}
if (update.type === 'text_end' && typeof update.content === 'string') {
return [{
op: 'message.upsert',
node: {
...message,
blocks: message.blocks.map((block) => block.id === blockId && block.kind === 'text'
? { ...block, text: update.content as string, status: 'complete' }
: block),
...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}),
},
}];
}
if (update.type === 'thinking_start') {
return [{
op: 'message.upsert',
node: {
...message,
blocks: [
...message.blocks.filter((block) => block.id !== blockId),
{ kind: 'thinking', id: blockId, text: '', status: 'streaming' },
],
...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}),
},
}];
}
if (update.type === 'thinking_delta' && typeof update.delta === 'string') {
return [{
op: 'message.block-delta',
messageId: message.id,
blockId,
delta: update.delta,
}];
}
if (update.type === 'thinking_end' && typeof update.content === 'string') {
return [{
op: 'message.upsert',
node: {
...message,
blocks: message.blocks.map((block) => block.id === blockId && block.kind === 'thinking'
? { ...block, text: update.content as string, status: 'complete' }
: block),
...(usageOf(event.usage) ? { usage: usageOf(event.usage) } : {}),
},
}];
}
if (update.type === 'toolcall_start') {
const id = this.createId();
this.toolDrafts.set(update.contentIndex, { id, argumentsText: '' });
return [{
op: 'tool.upsert',
node: {
kind: 'tool',
id,
toolCallId: `pending:${id}`,
toolName: 'tool',
title: 'tool',
inputText: '',
status: 'declared',
output: [],
},
}];
}
if (update.type === 'toolcall_delta' && typeof update.delta === 'string') {
const draft = this.toolDrafts.get(update.contentIndex);
if (draft) draft.argumentsText += update.delta;
return [];
}
if (update.type === 'toolcall_end') {
const toolCall = asRecord(update.toolCall);
if (!toolCall || typeof toolCall.id !== 'string' || typeof toolCall.name !== 'string') return [];
const draft = this.toolDrafts.get(update.contentIndex) ?? {
id: this.createId(),
argumentsText: '',
};
this.toolDrafts.delete(update.contentIndex);
return [{
op: 'tool.upsert',
node: {
kind: 'tool',
id: draft.id,
toolCallId: toolCall.id,
toolName: toolCall.name,
title: toolCall.name,
inputText: asRecord(toolCall.arguments)
? JSON.stringify(toolCall.arguments)
: draft.argumentsText,
status: 'declared',
output: [],
},
}];
}
return [];
}
if (event.type === 'tool_execution_start') {
if (typeof event.toolCallId !== 'string') return [];
const tool = currentTool(snapshot, event.toolCallId);
if (!tool) return [];
return [{ op: 'tool.upsert', node: { ...tool, status: 'running' } }];
}
if (event.type === 'tool_execution_update') {
if (typeof event.toolCallId !== 'string') return [];
const tool = currentTool(snapshot, event.toolCallId);
if (!tool) return [];
return projectToolResult(snapshot, tool, 'running', event.partialResult);
}
if (event.type === 'tool_execution_end') {
if (typeof event.toolCallId !== 'string') return [];
const tool = currentTool(snapshot, event.toolCallId);
if (!tool) return [];
return projectToolResult(
snapshot,
tool,
event.isError === true ? 'error' : 'complete',
event.result,
);
}
if (event.type === 'message_end') {
const message = asRecord(event.message);
if (message?.role === 'toolResult' && typeof message.toolCallId === 'string') {
const tool = currentTool(snapshot, message.toolCallId);
if (!tool) return [];
return projectToolResult(
snapshot,
tool,
message.isError === true ? 'error' : 'complete',
message,
);
}
if (message?.role !== 'assistant') return [];
const current = currentAssistant(snapshot);
const id = current?.id ?? this.assistantMessageId ?? this.createId();
this.assistantMessageId = null;
return [{
op: 'message.upsert',
node: {
kind: 'message',
id,
role: 'assistant',
status: message.stopReason === 'aborted'
? 'aborted'
: message.stopReason === 'error' ? 'error' : 'complete',
blocks: await liveContentBlocks(
id,
message.content,
snapshot.conversation.id,
this.projectImage,
),
...(usageOf(message.usage) ? { usage: usageOf(message.usage) } : {}),
...(stopReasonOf(message.stopReason) ? { stopReason: stopReasonOf(message.stopReason) } : {}),
},
}];
}
if (event.type !== 'agent_end') {
this.diagnostics.push({ eventType: event.type, reason: 'unsupported-event' });
if (this.diagnostics.length > 32) this.diagnostics.shift();
}
return [];
}
}