Z ZPI 文档 v0.3.8
介绍页

源码

packages/coding-agent/src/core/agent-session.ts

typescript1064 行查看原文
import { randomUUID } from "node:crypto";
import type { AgentEvent, AgentState, AgentTool, ThinkingLevel } from "@zpi-dev/agent";
import { Agent } from "@zpi-dev/agent";
import type {
  ImageContent,
  Message,
  Model,
  ModelRetryStatus,
  ModelWaitStatus,
  SimpleStreamOptions,
  TranscriptContext,
  UserGuide,
} from "@zpi-dev/ai";
import {
  assertSupportedOptions,
  clampThinkingLevel,
  createAssistantMessageEventStream,
  emptyAssistant,
  isContextOverflowMessage,
  isRecoverableLength,
  isRetryableAssistantError,
  ModelRequestRetryError,
  retryAttemptLimit,
  retryDelayMs,
} from "@zpi-dev/ai";
import { ResponsesSession } from "@zpi-dev/ai/api/responses-session";
import { expandPluginCommand } from "../plugins/assembly.ts";
import type { HookExecution, HookRegistration } from "../plugins/hooks.ts";
import { SessionHooks } from "../plugins/session-hooks.ts";
import type { PluginCommand } from "../plugins/types.ts";
import type { ParsedInput } from "./commands.ts";
import { listCommands, parseInput } from "./commands.ts";
import {
  calculateContextTokens,
  compact as compactContext,
  estimateContextTokens,
  prepareCompaction,
  shouldCompact,
} from "./compaction.ts";
import { contextUpdate } from "./dynamic-context.ts";
import { withHttpIdleTimeout } from "./http-idle-timeout.ts";
import { buildDefaultMicrocompactThreshold, maybeMicrocompact } from "./microcompact.ts";
import {
  type ModelPerformance,
  type ModelPerformanceTotals,
  SessionPerformance,
} from "./model-performance.ts";
import type { ModelRuntime } from "./model-runtime.ts";
import type { LoadedSkill, SkillList } from "./resources.ts";
import type { SessionEntry, SessionManager } from "./session-manager.ts";
import type { ContextUsage } from "./session-state.ts";
import { contextUsage } from "./session-state.ts";
import { SettingsManager } from "./settings-manager.ts";
import { StreamingToolJournal } from "./streaming-tool-journal.ts";
import type { SystemContext } from "./system-prompt.ts";
import type { ResourceDiagnostic, ResourceLoader } from "./types.ts";
export interface InputContext {
  images?: ImageContent[];
  fileReferences?: string[];
}
function withReferences(text: string, references?: string[]): string {
  const instructions = references?.some((path) => path.endsWith("/"))
    ? "Referenced project files and folders (use bash to list folders and read to inspect files when needed)"
    : "Referenced project files (read with the read tool when needed)";
  return references?.length
    ? `${text}\n\n${instructions}:\n${references.map((p) => `- ${JSON.stringify(p)}`).join("\n")}`
    : text;
}
function validateInputContext(input: ParsedInput, context: InputContext): void {
  if ((context.images?.length || context.fileReferences?.length) && input.kind === "compact")
    throw new Error("invalid_input: 此控制命令不接收图片或文件引用");
}
export type AgentSessionEvent =
  | { type: "hook_execution"; execution: HookExecution }
  | { type: "model_performance"; performance: ModelPerformance; totals: ModelPerformanceTotals }
  | Exclude<AgentEvent, { type: "agent_end" }>
  | { type: "agent_end"; messages: AgentState["messages"]; willRetry: boolean }
  | { type: "agent_settled" }
  | { type: "entry_appended"; entry: SessionEntry }
  | { type: "session_info_changed"; name: string }
  | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel }
  | { type: "model_retry"; status: ModelRetryStatus | null }
  | { type: "model_wait"; status: ModelWaitStatus | null }
  | {
      type: "compaction";
      id: string;
      status: "running" | "completed" | "aborted" | "error" | "noop";
      origin: "manual" | "auto";
      error?: string;
    }
  | { type: "command_result"; message: string };
export class AgentSession {
  private modelRequestOptions?: () => SimpleStreamOptions;
  private onCompaction?: (
    phase: "before" | "after",
    context: { manual: boolean; instructions: string },
    signal?: AbortSignal,
  ) => Promise<void>;
  private agent: Agent;
  private pluginCommands: readonly PluginCommand[];
  private hooks: SessionHooks;
  private manager: SessionManager;
  private steering: import("@zpi-dev/ai").UserMessage[] = [];
  private pendingMessages: import("@zpi-dev/ai").UserMessage[] = [];
  private allTools: Map<string, AgentTool>;
  private listeners = new Set<(event: AgentSessionEvent) => void>();
  private running?: Promise<void>;
  private active?: "prompt" | "compact";
  private disposed = false;
  private abortRequested = false;
  private fault?: Error;
  private runtime: ModelRuntime;
  private responsesSession?: ResponsesSession;
  private responsesWarmupTimer?: ReturnType<typeof setTimeout>;
  private responsesWarmupController?: AbortController;
  private resources?: {
    loader: ResourceLoader;
    buildContext: () => SystemContext;
    settingsManager?: SettingsManager;
  };
  private compactController?: AbortController;
  readonly settingsManager: SettingsManager;
  private overflowRecoveryAttempted = false;
  private retryAttempt = 0;
  private recoveryStartedAt?: number;
  private retryCurrentRequest?: () => boolean;
  private lastAssistantCompletedAtMs?: number;
  private microcompactProjection = new WeakMap<Message, Message>();
  private recoveredCalls = new WeakSet<object>();
  private performance: SessionPerformance;
  get modelPerformance(): ModelPerformance {
    return this.performance.value;
  }
  constructor(
    model: Model,
    runtime: ModelRuntime,
    manager: SessionManager,
    allTools: AgentTool[],
    activeTools: AgentTool[],
    systemPrompt: string,
    thinkingLevel: ThinkingLevel,
    resources?: {
      loader: ResourceLoader;
      buildContext: () => SystemContext;
      settingsManager?: SettingsManager;
    },
    performanceTotals?: ModelPerformanceTotals,
    extensions: {
      modelRequestOptions?: () => SimpleStreamOptions;
      onCompaction?: (
        phase: "before" | "after",
        context: { manual: boolean; instructions: string },
        signal?: AbortSignal,
      ) => Promise<void>;
      beforeModelRequest?: (model: Model, context: import("@zpi-dev/ai").Context) => void;
      commands?: readonly PluginCommand[];
      hooks?: readonly (HookRegistration & { pluginId?: string })[];
    } = {},
  ) {
    this.modelRequestOptions = extensions.modelRequestOptions;
    this.onCompaction = extensions.onCompaction;
    this.pluginCommands = structuredClone(extensions.commands ?? []);
    this.hooks = new SessionHooks(
      {
        sessionId: manager.getSessionId(),
        cwd: manager.getCwd(),
        source: manager.buildSessionContext().messages.some((m) => m.role === "user") ? "resume" : "startup",
      },
      extensions.hooks ?? [],
      (execution) => this.notify({ type: "hook_execution", execution }),
    );
    this.performance = new SessionPerformance(
      (performance, totals) => this.notify({ type: "model_performance", performance, totals }),
      performanceTotals,
    );
    this.manager = manager;
    this.runtime = runtime;
    this.resources = resources;
    this.settingsManager = resources?.settingsManager ?? SettingsManager.inMemory();
    this.allTools = new Map(allTools.map((t) => [t.name, t]));
    for (const message of initialSystemMessages(
      manager,
      activeTools,
      systemPrompt,
      resources?.buildContext(),
    ))
      manager.appendMessage(message);
    this.lastAssistantCompletedAtMs = manager
      .buildSessionContext()
      .messages.findLast(
        (message) =>
          message.role === "assistant" && message.stopReason !== "error" && message.stopReason !== "aborted",
      )?.timestamp;
    this.agent = new Agent({
      initialState: {
        model,
        thinkingLevel,
        messages: manager.buildSessionContext().messages,
        tools: activeTools,
      },
      streamFn: (m, c, o) => {
        extensions.beforeModelRequest?.(m, c);
        return this.streamWithRecovery(m, c, o);
      },
      transformContext: async (messages) => {
        const config = this.settingsManager.getMicrocompactSettings();
        const settings = this.settingsManager.getCompactionSettings(this.model);
        const projected = messages.map((message) => this.microcompactProjection.get(message) ?? message);
        const result = maybeMicrocompact({
          messages: projected,
          config: {
            ...config,
            thresholdTokens:
              config.thresholdTokens ??
              buildDefaultMicrocompactThreshold(this.model.contextWindow - settings.reserveTokens),
          },
          lastAssistantCompletedAtMs: this.lastAssistantCompletedAtMs,
        });
        if (result.boundary) {
          for (const [index, message] of messages.entries())
            if (result.messages[index] !== message)
              this.microcompactProjection.set(message, result.messages[index]);
          await this.manager.appendEntryAsync({ type: "microcompact", data: result.boundary });
          this.notifyLastEntry();
        }
        return result.messages;
      },
      prepareNextTurnWithContext: async (turn, signal) => {
        const updated = await this.syncContext();
        const settings = this.settingsManager.getCompactionSettings(this.model);
        if (
          this.model.contextWindow > 0 &&
          shouldCompact(this.estimateTokens(turn.context.messages), this.model.contextWindow, settings)
        ) {
          if (await this.tryAutoCompact(signal))
            return { context: { ...turn.context, messages: this.manager.buildSessionContext().messages } };
        }
        return updated
          ? { context: { ...turn.context, messages: this.manager.buildSessionContext().messages } }
          : undefined;
      },
      beforeToolCall: this.hooks.runner.hasToolHooks ? this.hooks.beforeToolCall : undefined,
      afterToolCall: this.hooks.runner.hasToolHooks ? this.hooks.afterToolCall : undefined,
      beforeStop: this.hooks.beforeStop,
      streamingToolExecution: this.hooks.runner.hasToolHooks ? "off" : "readOnly",
      toolExecution: "parallel",
      getSteeringMessages: () => this.steering.splice(0),
      getPendingMessages: () => [...this.hooks.drainMessages(), ...this.pendingMessages.splice(0)],
    });
    const streamingTools = new StreamingToolJournal(manager, () => this.notifyLastEntry());
    this.agent.subscribe(async (event) => {
      try {
        if (event.type === "tool_execution_start") this.performance.toolStart(event.toolCallId);
        if (event.type === "tool_execution_end") this.performance.toolEnd(event.toolCallId);
        await streamingTools.observe(event);
        if (
          event.type === "message_end" &&
          event.message.role === "assistant" &&
          !this.recoveredCalls.has(event.message)
        ) {
          if (!["error", "length"].includes(event.message.stopReason)) this.overflowRecoveryAttempted = false;
          if (event.message.stopReason !== "error") {
            this.notify({ type: "model_retry", status: null });
            this.retryAttempt = 0;
            this.recoveryStartedAt = undefined;
          }
        }
        if (event.type === "message_end") {
          if (event.message.role === "assistant" && !["error", "aborted"].includes(event.message.stopReason))
            this.lastAssistantCompletedAtMs = Date.now();
          await this.manager.appendEntryAsync({ type: "message", message: event.message });
          this.notifyLastEntry();
        }
      } catch (e) {
        this.fault = e instanceof Error ? e : new Error(String(e));
        this.agent.abort();
        throw this.fault;
      }
      const last = event.type === "agent_end" ? event.messages.at(-1) : undefined;
      this.notify(
        event.type === "agent_end"
          ? {
              ...event,
              willRetry: last?.role === "assistant" && this.canRetry(last),
            }
          : event,
      );
    });
    this.prepareResponsesSession();
  }
  private prepareResponsesSession(): void {
    clearTimeout(this.responsesWarmupTimer);
    this.responsesWarmupTimer = undefined;
    this.responsesWarmupController?.abort();
    this.responsesWarmupController = undefined;
    this.responsesSession?.dispose();
    this.responsesSession = undefined;
    if (!this.runtime.canUseResponsesSession(this.model)) return;
    const connection = new ResponsesSession({
      resolveProxy: this.runtime.responsesProxy,
      onDiagnostic: this.runtime.responsesDiagnosticObserver(this.model, this.sessionId),
    });
    this.responsesSession = connection;
    // Let callers finish selecting model + reasoning before capturing warmup parameters.
    this.responsesWarmupTimer = setTimeout(() => {
      this.responsesWarmupTimer = undefined;
      if (this.disposed || connection !== this.responsesSession) return;
      const controller = new AbortController();
      this.responsesWarmupController = controller;
      // Best effort, concurrent with resource loading/user input; never journaled or measured.
      void Promise.resolve()
        .then(() => {
          if (controller.signal.aborted || connection !== this.responsesSession) return;
          return this.runtime
            .streamSimple(
              this.model,
              { messages: this.messages },
              {
                sessionId: this.sessionId,
                responsesSession: connection,
                responsesWarmup: true,
                reasoning: this.thinkingLevel,
                signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]),
                streamFirstResponseTimeoutMs: 15_000,
                maxRetries: 0,
              },
            )
            .result();
        })
        .catch(() => {})
        .finally(() => {
          if (this.responsesWarmupController === controller) this.responsesWarmupController = undefined;
        });
    }, 0);
    this.responsesWarmupTimer.unref();
  }
  static async create(...args: ConstructorParameters<typeof AgentSession>): Promise<AgentSession> {
    const [, , manager, , activeTools, systemPrompt, , resources] = args;
    for (const message of initialSystemMessages(
      manager,
      activeTools,
      systemPrompt,
      resources?.buildContext(),
    ))
      await manager.appendEntryAsync({ type: "message", message });
    return new AgentSession(...args);
  }
  get state(): AgentState {
    return this.agent.state;
  }
  get messages(): AgentState["messages"] {
    return this.state.messages;
  }
  get model(): Model {
    return this.state.model;
  }
  get thinkingLevel(): ThinkingLevel {
    return this.state.thinkingLevel;
  }
  get isStreaming(): boolean {
    return this.active !== undefined;
  }
  get isIdle(): boolean {
    return !this.active;
  }
  get systemPrompt(): string {
    return this.state.systemPrompt;
  }
  getContextUsage(): ContextUsage {
    const entries = this.manager.getContextEntries();
    const estimate = estimateContextTokens(this.messages);
    const settings = this.settingsManager.getCompactionSettings(this.model);
    return {
      ...contextUsage(entries, this.model),
      estimatedTokens: this.estimateTokens(this.messages, estimate),
      estimateSource: estimate.lastUsageIndex === null ? "local" : "server usage + local",
      compaction: {
        enabled: settings.enabled,
        reserveTokens: settings.reserveTokens,
        threshold: this.model.contextWindow - settings.reserveTokens,
        compacted: entries.some((entry) => entry.type === "compaction"),
      },
    };
  }
  listSkills(): SkillList {
    return this.resources?.loader.listSkills?.() ?? { skills: [], diagnostics: [] };
  }
  getResourceDiagnostics(): ResourceDiagnostic[] {
    return [
      ...(this.resources?.loader.getDiagnostics?.() ?? []),
      ...this.settingsManager.getErrors().map(({ path, error }) => ({ path, message: error.message })),
    ];
  }
  async loadSkill(name: string): Promise<LoadedSkill> {
    if (!this.resources?.loader.loadSkill) throw new Error(`Skill not found: ${name}`);
    return this.resources.loader.loadSkill(name);
  }
  async prepareInput(text: string, context: InputContext = {}): Promise<ParsedInput> {
    this.idleCheck();
    const parsed = parseInput(text);
    validateInputContext(parsed, context);
    if (parsed.kind === "prompt") {
      const invocation = /^\s*\/(\S+)(?:\s+([\s\S]*))?$/.exec(text);
      const command = invocation && this.pluginCommands.find((command) => command.name === invocation[1]);
      if (command)
        return {
          kind: "prompt",
          text: expandPluginCommand(command, invocation?.[2]?.trim() ?? "", this.manager.getCwd()),
        };
    }
    if (parsed.kind === "compact" && !this.alreadyCompacted()) this.compactionBoundary();
    return parsed;
  }
  listCommands(): { name: string; description: string }[] {
    return listCommands(this.pluginCommands);
  }
  async submit(text: string): Promise<void> {
    return this.executeInput(await this.prepareInput(text));
  }
  async executeInput(input: ParsedInput, context: InputContext = {}): Promise<void> {
    validateInputContext(input, context);
    if (input.kind === "compact") return this.compact(input.args);
    if (input.kind !== "prompt") throw new Error("Input must be prepared before execution");
    return this.prompt(withReferences(input.text, context.fileReferences), { images: context.images });
  }
  private async syncContext(): Promise<boolean> {
    const current = this.resources?.buildContext();
    if (!current) return false;
    const update = contextUpdate(this.manager.buildSessionContext().messages, {
      "zpi.base": current.base,
      ...current.sections,
    });
    if (!update) return false;
    await this.manager.appendEntryAsync({ type: "message", message: update });
    this.notifyLastEntry();
    return true;
  }
  private async refreshInstructions(): Promise<void> {
    await this.resources?.loader.reload?.();

    if (await this.syncContext()) this.agent.state.messages = this.manager.buildSessionContext().messages;
  }
  private compactionBoundary() {
    const preparation = prepareCompaction(
      this.manager.getContextEntries(),
      this.settingsManager.getCompactionSettings(this.model),
    );
    if (!preparation) throw new Error("invalid_input: 没有可压缩的完整对话内容");
    return preparation;
  }
  private estimateTokens(
    messages: AgentState["messages"],
    estimate = estimateContextTokens(messages),
  ): number {
    const source = estimate.lastUsageIndex === null ? undefined : messages[estimate.lastUsageIndex];
    if (source?.role === "assistant" && this.precedesCompaction(source)) return 0;
    return estimate.tokens;
  }
  private precedesCompaction(message: AgentState["messages"][number]): boolean {
    return this.manager.precedesCompaction(message);
  }
  private async tryAutoCompact(signal?: AbortSignal): Promise<boolean> {
    if (
      !prepareCompaction(
        this.manager.getContextEntries(),
        this.settingsManager.getCompactionSettings(this.model),
      )
    )
      return false;
    try {
      await this.summarize(false, "", signal);
      return true;
    } catch (error) {
      if (signal?.aborted) throw error;
      return false;
    }
  }
  private async checkCompaction(signal?: AbortSignal, includeAborted = false): Promise<boolean> {
    const message = this.messages.findLast((message) => message.role === "assistant");
    const settings = this.settingsManager.getCompactionSettings(this.model);
    if (
      !settings.enabled ||
      !message ||
      message.role !== "assistant" ||
      (!includeAborted && message.stopReason === "aborted")
    )
      return false;
    if (this.precedesCompaction(message)) return false;
    const sameModel = message.provider === this.model.provider && message.model === this.model.id;
    const overflow =
      sameModel &&
      (isContextOverflowMessage(message, this.model.contextWindow) ||
        isRecoverableLength(message, this.model.maxTokens));
    if (overflow) {
      const retry = message.stopReason !== "stop";
      if (retry && this.overflowRecoveryAttempted) return false;
      if (retry) {
        this.overflowRecoveryAttempted = true;
      }
      if (!(await this.tryAutoCompact(signal))) return false;
      this.state.messages = this.manager.buildSessionContext().messages;
      if (retry) this.dropFailedAssistant();
      return retry;
    }
    const direct = message.stopReason === "error" ? 0 : calculateContextTokens(message.usage);
    const tokens = direct || this.estimateTokens(this.messages);
    if (shouldCompact(tokens, this.model.contextWindow, settings) && (await this.tryAutoCompact(signal)))
      this.state.messages = this.manager.buildSessionContext().messages;
    return false;
  }
  private dropFailedAssistant(): void {
    const index = this.messages.findLastIndex((message) => message.role !== "system");
    const last = this.messages[index];
    if (last?.role === "assistant" && ["error", "length"].includes(last.stopReason))
      this.state.messages = this.messages.filter((_, i) => i !== index);
  }
  private async waitForRetry(
    message: Extract<AgentState["messages"][number], { role: "assistant" }>,
    signal?: AbortSignal,
  ): Promise<boolean> {
    if (!this.canRetry(message)) return false;
    const settings = this.settingsManager.getRetrySettings();
    const manualRetry = message.errorDetails?.code === "MODEL_REQUEST_RETRY";
    if (manualRetry) {
      this.retryAttempt = 0;
      this.recoveryStartedAt = Date.now();
    } else this.recoveryStartedAt ??= Date.now();
    this.dropFailedAssistant();
    this.retryAttempt++;
    const delay = manualRetry
      ? 0
      : retryDelayMs(settings, this.retryAttempt, message.errorDetails?.retryAfterMs);
    this.notify({
      type: "model_retry",
      status: {
        origin: manualRetry ? "manual" : "auto",
        attempt: this.retryAttempt,
        maxRetries: settings.maxRetries,
        retryDelayMs: delay,
        errorStatus: message.errorDetails?.status ?? null,
      },
    });
    if (await this.waitForRetryDelay(delay, signal)) {
      this.recoveryStartedAt = Date.now();
      this.retryAttempt = 1;
    }
    return true;
  }
  private waitForRetryDelay(delay: number, signal?: AbortSignal): Promise<boolean> {
    return new Promise<boolean>((resolve, reject) => {
      const cleanup = () => {
        clearTimeout(timer);
        signal?.removeEventListener("abort", abort);
        if (this.retryCurrentRequest === retryNow) this.retryCurrentRequest = undefined;
      };
      const retryNow = () => {
        cleanup();
        resolve(true);
        return true;
      };
      const abort = () => {
        cleanup();
        reject(new DOMException("Request aborted", "AbortError"));
      };
      const timer = setTimeout(() => {
        cleanup();
        resolve(false);
      }, delay);
      this.retryCurrentRequest = retryNow;
      if (signal?.aborted) abort();
      else signal?.addEventListener("abort", abort, { once: true });
    });
  }
  private canRetry(message: Extract<AgentState["messages"][number], { role: "assistant" }>): boolean {
    const settings = this.settingsManager.getRetrySettings();
    if (!this.abortRequested && message.errorDetails?.code === "MODEL_REQUEST_RETRY") return true;
    const deadline = this.recoveryDeadline(this.recoveryStartedAt ?? Date.now());
    const delay = retryDelayMs(settings, this.retryAttempt + 1, message.errorDetails?.retryAfterMs);
    return (
      !this.abortRequested &&
      !(
        !settings.enabled ||
        this.retryAttempt >= retryAttemptLimit(message, settings.maxRetries) ||
        !Number.isFinite(delay) ||
        (deadline !== undefined && Date.now() + delay >= deadline) ||
        isContextOverflowMessage(message, this.model.contextWindow) ||
        !isRetryableAssistantError(message)
      )
    );
  }
  private recoveryDeadline(startedAt = this.recoveryStartedAt): number | undefined {
    const limit = this.settingsManager.getRetrySettings().maxElapsedMs;
    return limit && limit > 0 && startedAt !== undefined ? startedAt + limit : undefined;
  }
  /** Only the active model request or retry backoff can be retried; tools keep their lifecycle. */
  retryModelRequest(): boolean {
    if (this.abortRequested || this.disposed || !this.active) return false;
    if (!this.retryCurrentRequest?.()) return false;
    this.notify({
      type: "model_retry",
      status: {
        origin: "manual",
        attempt: 1,
        maxRetries: this.settingsManager.getRetrySettings().maxRetries,
        retryDelayMs: 0,
        errorStatus: null,
      },
    });
    return true;
  }
  private requestOptions(options: SimpleStreamOptions = {}): SimpleStreamOptions {
    const request = { ...options, ...this.modelRequestOptions?.(), signal: options.signal };
    const provider = this.settingsManager.getProviderRetrySettings();
    const idle = this.settingsManager.getHttpIdleTimeoutMs();
    const requestFetch = request.fetch ?? this.runtime.fetch;
    return {
      ...provider,
      ...request,
      timeoutMs: request.timeoutMs ?? provider.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
      streamIdleTimeoutMs: request.streamIdleTimeoutMs ?? this.settingsManager.getStreamIdleTimeoutMs(),
      streamFirstResponseTimeoutMs:
        request.streamFirstResponseTimeoutMs ?? this.settingsManager.getStreamFirstResponseTimeoutMs(),
      maxRetries: request.maxRetries ?? provider.maxRetries,
      maxRetryDelayMs: request.maxRetryDelayMs ?? provider.maxRetryDelayMs,
      fetch: withHttpIdleTimeout(requestFetch, idle),
    };
  }
  private alreadyCompacted(): boolean {
    const state = this.manager.getStateAt();
    return state.lastCompactionSeq > 0 && state.lastMessageSeq < state.lastCompactionSeq;
  }
  private async summarize(manual: boolean, instructions: string, signal?: AbortSignal): Promise<void> {
    const id = randomUUID();
    const origin = manual ? "manual" : "auto";
    this.notify({ type: "compaction", id, status: "running", origin });
    try {
      await this.onCompaction?.("before", { manual, instructions }, signal);
      await this.summarizeContext(manual, instructions, signal);
      await this.onCompaction?.("after", { manual, instructions }, signal);
      this.notify({ type: "compaction", id, status: "completed", origin });
    } catch (error) {
      this.notify({
        type: "compaction",
        id,
        status: signal?.aborted ? "aborted" : "error",
        origin,
        error: error instanceof Error ? error.message : String(error),
      });
      throw error;
    }
  }
  private async summarizeContext(manual: boolean, instructions: string, signal?: AbortSignal): Promise<void> {
    signal?.throwIfAborted();
    const preparation = this.compactionBoundary();
    const result = await compactContext(
      preparation,
      this.model,
      undefined,
      undefined,
      instructions || undefined,
      signal,
      this.thinkingLevel,
      (model, context, options) => this.measuredStream(model, context, this.requestOptions(options)),
      this.settingsManager.getRetrySettings(),
      {
        onRetryScheduled: (attempt, maxRetries, retryDelayMs, _errorMessage, origin) => {
          this.notify({
            type: "model_retry",
            status: { origin, attempt, maxRetries, retryDelayMs, errorStatus: null },
          });
        },
        onRetryFinished: () => this.notify({ type: "model_retry", status: null }),
        waitBeforeRetry: (delay, signal) => this.waitForRetryDelay(delay, signal),
      },
      randomUUID(),
      this.runtime.mediaProjectionObserver(this.model, this.sessionId),
    );
    signal?.throwIfAborted();
    await this.manager.appendCompactionAsync(
      result.summary,
      result.firstKeptEntryId,
      result.tokensBefore,
      result.details as { readFiles: string[]; modifiedFiles: string[] },
      result.usage,
    );
    this.notifyLastEntry();
    this.notify({
      type: "command_result",
      message: manual
        ? "上下文已压缩;完整历史保留,下一次请求使用摘要和最近回合。"
        : "上下文接近容量,已自动压缩。",
    });
  }
  private streamWithRecovery(model: Model, context: TranscriptContext, options: SimpleStreamOptions = {}) {
    // If input is already ready, generate directly instead of queueing a late warmup behind it.
    clearTimeout(this.responsesWarmupTimer);
    this.responsesWarmupTimer = undefined;
    const output = createAssistantMessageEventStream();
    let partial = emptyAssistant(model);
    void (async () => {
      const response = this.measuredStream(
        model,
        context,
        this.requestOptions({
          ...options,
          sessionId: this.sessionId,
          ...(this.responsesSession ? { responsesSession: this.responsesSession } : {}),
          streamDeadlineMs:
            options.streamDeadlineMs ?? (this.retryAttempt > 0 ? this.recoveryDeadline() : undefined),
        }),
      );
      let started = false;
      for await (const event of response) {
        partial =
          event.type === "error" ? event.error : event.type === "done" ? event.message : event.partial;
        if (event.type === "start") {
          started = true;
          // The response stream is connected again; keep the retry budget until
          // the message succeeds, but stop showing the reconnect indicator now.
          if (this.retryAttempt > 0) this.notify({ type: "model_retry", status: null });
          output.push(event);
          continue;
        }
        if (
          event.type === "error" &&
          event.error.content.some((block) => block.type === "toolCall") &&
          (await this.waitForRetry(event.error, options.signal))
        ) {
          // Preserve the existing streamed-read recovery: drain completed calls once,
          // discard partial prose and continue with their paired results.
          const recovered = emptyAssistant(model);
          recovered.content = event.error.content.filter((block) => block.type === "toolCall");
          recovered.stopReason = "toolUse";
          this.recoveredCalls.add(recovered);
          if (!started) output.push({ type: "start", partial: recovered });
          output.push({ type: "reset", partial: recovered });
          output.end(recovered);
          return;
        }
        output.push(event);
      }
    })().catch((error: unknown) => {
      const result = partial;
      result.stopReason = options.signal?.aborted ? "aborted" : "error";
      result.errorMessage = error instanceof Error ? error.message : String(error);
      output.end(result);
    });
    return output;
  }
  private measuredStream(model: Model, context: TranscriptContext, options: SimpleStreamOptions) {
    const output = createAssistantMessageEventStream();
    const request = this.performance.request();
    const retry = new AbortController();
    const retryNow = () => {
      if (retry.signal.aborted || options.signal?.aborted) return false;
      retry.abort(new ModelRequestRetryError());
      return true;
    };
    this.retryCurrentRequest = retryNow;
    void (async () => {
      try {
        const response = this.runtime.streamSimple(model, context, {
          ...options,
          onPerformance: request.update,
          retrySignal: retry.signal,
          onModelWait: (status) => this.notify({ type: "model_wait", status }),
        });
        for await (const event of response) {
          if (event.type === "done" || event.type === "error") {
            if (this.retryCurrentRequest === retryNow) this.retryCurrentRequest = undefined;
            request.finish(
              event.type === "done" && event.reason !== "length" && !options.signal?.aborted,
              event.type === "done" ? event.message : event.error,
            );
          }
          output.push(event);
        }
      } catch (error) {
        const failure = emptyAssistant(model);
        failure.stopReason = options.signal?.aborted ? "aborted" : "error";
        failure.errorMessage = error instanceof Error ? error.message : String(error);
        output.end(failure);
      } finally {
        if (this.retryCurrentRequest === retryNow) this.retryCurrentRequest = undefined;
        request.finish(false);
      }
    })();
    return output;
  }
  compact(instructions = ""): Promise<void> {
    try {
      this.idleCheck();
      if (this.alreadyCompacted()) {
        this.notify({ type: "compaction", id: randomUUID(), status: "noop", origin: "manual" });
        this.notify({ type: "command_result", message: "上下文已是最新,无需压缩" });
        return Promise.resolve();
      }
      this.compactionBoundary();
    } catch (error) {
      return Promise.reject(error);
    }
    this.active = "compact";
    this.performance.begin();
    this.abortRequested = false;
    this.retryAttempt = 0;
    this.recoveryStartedAt = undefined;
    const controller = new AbortController();
    this.compactController = controller;
    this.running = (async () => {
      await this.resources?.loader.reload?.();
      await this.summarize(true, instructions, controller.signal);
      this.state.messages = this.manager.buildSessionContext().messages;
    })().finally(() => {
      this.performance.settle();
      this.compactController = undefined;
      this.retryCurrentRequest = undefined;
      this.notify({ type: "model_retry", status: null });
      this.notify({ type: "model_wait", status: null });
      this.active = undefined;
      this.running = undefined;
      this.notify({ type: "agent_settled" });
    });
    return this.running;
  }
  get sessionId(): string {
    return this.manager.getSessionId();
  }
  get sessionFile(): string | undefined {
    return this.manager.getSessionFile();
  }
  get sessionName(): string | undefined {
    return this.manager.getCurrentState().name;
  }
  subscribe(listener: (event: AgentSessionEvent) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
  private notify(event: AgentSessionEvent): void {
    for (const l of this.listeners) {
      try {
        const result: unknown = l(event);
        if (result instanceof Promise) void result.catch(() => {});
      } catch {
        /* Notification listeners cannot control persistence or settlement. */
      }
    }
  }
  private notifyLastEntry(): void {
    const entry = this.manager.getLastEntry();
    if (entry) this.notify({ type: "entry_appended", entry });
  }
  private idleCheck(): void {
    if (this.disposed) throw new Error("Session disposed");
    if (this.fault) throw this.fault;
    if (this.active) throw new Error("Session busy");
  }
  prompt(text: string, options: { images?: ImageContent[] } = {}): Promise<void> {
    try {
      this.idleCheck();
      assertSupportedOptions(options, ["images"], "Session.prompt");
      this.validateImages(options.images);
    } catch (e) {
      return Promise.reject(e);
    }
    this.active = "prompt";
    this.hooks.beginRun();
    this.performance.begin();
    this.abortRequested = false;
    this.overflowRecoveryAttempted = false;
    this.retryAttempt = 0;
    this.recoveryStartedAt = undefined;
    this.compactController = new AbortController();
    this.running = Promise.resolve()
      .then(async () => {
        if (this.abortRequested) return;
        const signal = this.compactController?.signal;
        const hookMessages = await this.hooks.preparePrompt(text, signal);
        if (signal?.aborted) return;
        await this.refreshInstructions();
        if (signal?.aborted) return;
        await this.checkCompaction(signal, true);
        if (signal?.aborted) return;
        await this.agent.prompt([
          ...hookMessages,
          {
            role: "user",
            content: options.images?.length ? [{ type: "text", text }, ...options.images] : text,
            timestamp: Date.now(),
          },
        ]);
        while (!signal?.aborted) {
          const last = this.messages.findLast((message) => message.role === "assistant");
          if (last?.role === "assistant" && (await this.waitForRetry(last, signal))) {
            // 失败请求已结束,重试必须先接入待引导输入,不能一直用旧上下文重试。
            if (this.steering.length) await this.agent.prompt(this.steering.splice(0));
            else await this.agent.continue();
            continue;
          }
          if (this.steering.length) {
            await this.agent.prompt(this.steering.splice(0));
            continue;
          }
          const compacted = await this.checkCompaction(signal);
          if (!signal?.aborted && this.steering.length) {
            await this.agent.prompt(this.steering.splice(0));
            continue;
          }
          if (!compacted) break;
          await this.agent.continue();
        }
      })
      .catch((error) => {
        if (!this.abortRequested) throw error;
      })
      .finally(() => {
        this.performance.settle();
        this.compactController = undefined;
        this.notify({ type: "model_retry", status: null });
        this.notify({ type: "model_wait", status: null });
        this.retryCurrentRequest = undefined;
        this.active = undefined;
        this.running = undefined;
        this.steering = [];
        this.pendingMessages = [];
        this.notify({ type: "agent_settled" });
      });
    return this.running;
  }
  /** Queue input for the next completed model/tool boundary without interrupting generation. */
  steer(
    text: string,
    options: {
      queueOnly?: boolean;
      inputId?: string;
      userGuide?: UserGuide;
      images?: ImageContent[];
      fileReferences?: string[];
    } = {},
  ): boolean {
    this.validateImages(options.images);
    if ((!text.trim() && !options.images?.length) || (!options.queueOnly && text.length > 100000))
      throw new Error("Invalid steering input");
    if (this.active !== "prompt" || this.disposed || this.abortRequested) return false;
    (options.queueOnly ? this.pendingMessages : this.steering).push({
      role: "user",
      content: options.images?.length
        ? [{ type: "text", text: withReferences(text, options.fileReferences) }, ...options.images]
        : withReferences(text, options.fileReferences),
      timestamp: Date.now(),
      ...(options.inputId ? { inputId: options.inputId } : {}),
      ...(options.userGuide ? { userGuide: options.userGuide } : {}),
    });
    return true;
  }
  async abort(): Promise<void> {
    this.abortRequested = true;
    this.compactController?.abort();
    this.agent.abort();
    await this.running;
  }
  async waitForIdle(): Promise<void> {
    await this.running;
  }
  dispose(): void {
    clearTimeout(this.responsesWarmupTimer);
    this.responsesWarmupController?.abort();
    this.responsesSession?.dispose();
    this.performance.dispose();
    this.disposed = true;
    this.compactController?.abort();
    this.agent.abort();
    this.listeners.clear();
  }
  getToolDefinitions(): AgentTool[] {
    return [...this.allTools.values()];
  }
  validateImages(images?: ImageContent[]): void {
    if (this.model.input.includes("image")) return;
    if (
      images?.length ||
      this.messages.some(
        (m) => m.role === "user" && Array.isArray(m.content) && m.content.some((c) => c.type === "image"),
      )
    )
      throw new Error(
        "configuration: 当前模型不支持图片;请选择图片模型或移除新附件(有效历史中的图片也需图片模型)",
      );
  }
  getActiveToolNames(): string[] {
    return this.state.tools.map((t) => t.name);
  }
  setActiveToolsByName(names: string[]): void {
    this.idleCheck();
    this.state.tools = names.map((n) => {
      const t = this.allTools.get(n);
      if (!t) throw new Error(`Unknown tool ${n}`);
      return t;
    });
  }
  async setModel(model: Model): Promise<void> {
    this.idleCheck();
    const changed =
      model.provider !== this.model.provider ||
      model.id !== this.model.id ||
      model.api !== this.model.api ||
      model.baseUrl !== this.model.baseUrl ||
      model.auth !== this.model.auth;
    if (model.provider !== this.model.provider || model.id !== this.model.id) {
      if (this.manager.appendModelChange(model.provider, model.id)) this.notifyLastEntry();
    }
    this.state.model = model;
    const thinkingLevel = clampThinkingLevel(model, this.thinkingLevel);
    if (thinkingLevel !== this.thinkingLevel) this.setThinkingLevel(thinkingLevel);
    if (changed) this.prepareResponsesSession();
  }
  setThinkingLevel(thinkingLevel: ThinkingLevel): void {
    this.idleCheck();
    const changed = thinkingLevel !== this.thinkingLevel;
    const entryId = this.manager.appendThinkingLevelChange(thinkingLevel);
    this.state.thinkingLevel = thinkingLevel;
    if (entryId) this.notifyLastEntry();
    this.notify({ type: "thinking_level_changed", thinkingLevel });
    if (changed && (this.responsesWarmupTimer || this.responsesWarmupController))
      this.prepareResponsesSession();
  }
  setSessionName(name: string): void {
    this.idleCheck();
    if (this.manager.appendSessionInfo(name)) this.notifyLastEntry();
    this.notify({ type: "session_info_changed", name });
  }
}

function initialSystemMessages(
  manager: SessionManager,
  activeTools: AgentTool[],
  systemPrompt: string,
  context?: SystemContext,
): Extract<Message, { role: "system" }>[] {
  const messages: Extract<Message, { role: "system" }>[] = [];
  const restored = manager.buildSessionContext();
  // Restored plugin readers may not be prepared until the prompt hooks run.
  // Keep the last effective state until that boundary instead of withdrawing it at startup.
  if (restored.messages.some((m) => m.role === "system")) return messages;
  messages.push({
    role: "system",
    content: "",
    sections: { "zpi.base": systemPrompt },
    toolsAdded: activeTools.map((t) => ({
      name: t.name,
      description: t.description,
      parameters: t.parameters,
    })),
    timestamp: Date.now(),
  });
  if (context) {
    const update = contextUpdate([...restored.messages, ...messages], {
      "zpi.base": context.base,
      ...context.sections,
    });
    if (update) messages.push(update);
  }
  return messages;
}