Ctrl K
ComunidadGuías…

agents/opencodeSession.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * What the two OpenCode generations share once a session is open: the run state the turn loop in
 * `opencode.ts` reads, the contract each driver (`opencode1.ts`, `opencode2.ts`) implements, and the
 * bookkeeping every tool call, text block and turn end goes through. Each driver maps its own event
 * stream onto these, so the side effects — diff coverage, todos, the salvage gate's MCP count —
 * cannot drift between generations. See wiki/opencode-v2.md.
 */
import type { ChildProcess } from "node:child_process";
import { performance } from "node:perf_hooks";
import type { AgentDiagnostic } from "../utils/agentHangReport.ts";
import { formatJsonValue, log } from "../utils/cli.ts";
import { type RunEffort, resolveRunEffort } from "../utils/runEffort.ts";
import type { OpenCodeConfig } from "./opencodeShared.ts";
import {
  formatWithLabel,
  ORCHESTRATOR_LABEL,
  SessionLabeler,
  type TaskDispatchInput,
} from "./sessionLabeler.ts";
import {
  type AgentResult,
  type AgentRunContext,
  type AgentUsage,
  logTokenTable,
} from "./shared.ts";

export interface ServerHandle {
  baseUrl: string;
  proc: ChildProcess;
  /** kill the server; idempotent. */
  close: () => Promise<void>;
  /** rolling tail of server stderr for diagnostics. */
  recentStderr: string[];
  /** why the server died on its own after boot; undefined while it is up or once `close()` ran. */
  exitCause: () => string | undefined;
}

/**
 * The native-tool sandbox, as V1's `OPENCODE_PERMISSION` spells it: per action, resource
 * patterns in last-match-wins order. V2 takes the same rules as session permissions.
 */
export type Sandbox = Record<string, Record<string, "allow" | "deny">>;

/** one OpenCode generation's half of the harness. see `opencode1.ts` and `opencode2.ts`. */
export interface OpencodeDriver {
  /** the gate plugin, in this generation's plugin API. */
  gatePlugin: (ctx: AgentRunContext) => string;
  /** the spawn env only this generation reads: its config, its sandbox, its server auth. */
  env: (params: {
    config: OpenCodeConfig;
    sandbox: Sandbox;
    home: string;
    configHome: string;
    repoDir: string;
  }) => Record<string, string>;
  /** open the run's session on the booted server. throws when the server refuses it. */
  open: (launch: OpencodeLaunch) => Promise<OpencodeSession>;
}

/** everything a driver needs to open the run's session on a booted server. */
export interface OpencodeLaunch {
  ctx: AgentRunContext;
  server: ServerHandle;
  sandbox: Sandbox;
  /** the env the server was spawned with. */
  env: NodeJS.ProcessEnv;
  repoDir: string;
  /** `<provider>/<model>` as opencode names it; undefined lets opencode choose. */
  model: string | undefined;
  /** the run's model before bedrock/vertex prefixing, which effort resolves against. */
  rawModel: string | undefined;
  effort: RunEffort;
  /** a fetch with no client-side timeouts: the activity watchdog, not undici, bounds a stall. */
  fetch: typeof fetch;
  diagnostic: AgentDiagnostic;
}

export interface OpencodeSession {
  state: RunState;
  /** run one prompt turn to completion. */
  prompt: (text: string, signal: AbortSignal) => Promise<AgentResult>;
  /** stop one session's in-flight work. a subagent's dispatching call then fails. */
  interrupt: (sessionID: string) => Promise<void>;
  /** tell the orchestrator something at its next step, without starting a turn. */
  note: (text: string) => Promise<void>;
  /** stop the event stream. the caller closes the server. */
  close: () => Promise<void>;
}

/** what a single turn collects for its result. reset by the driver at each turn start. */
export interface TurnText {
  finalText: string;
}

export interface RunState {
  label: string;
  orchestratorSessionID: string;
  labeler: SessionLabeler;
  todoTracker: AgentRunContext["todoTracker"];
  onToolUse: AgentRunContext["onToolUse"];
  currentTurn: TurnText | null;
  /** monotonic event count for diagnostics. */
  eventCount: number;
  /** last meaningful-progress timestamp, the inner watchdog's clock. */
  lastEventAt: number;
  /** subagent dispatches in flight, keyed by the dispatching tool call's id. */
  taskDispatchByCallID: Map<
    string,
    {
      label: string;
      startedAt: number;
      subagentType: string | undefined;
      child?: { sessionID: string; startedAt: number };
      /** an interrupt has landed and been logged. */
      interrupted?: boolean;
    }
  >;
  /** the run has been outside its deadline reserve, so entering it earns the one submit note. */
  reserveNoteArmed: boolean;
  /** first dispatch time per child session, so a lens resumed by its session id keeps its spent budget. */
  childStartedAt: Map<string, number>;
  /** orchestrator tool calls already bookkept, so a replay never fires side effects twice. */
  loggedToolCallIDs: Set<string>;
  /**
   * Count of orchestrator calls to a Pullfrog MCP tool (`pullfrog_*`). Every
   * artifact a run can leave behind — a review, a progress report, a push, a
   * commit through the shell — goes through one, whereas `read`/`grep`/`glob`
   * leave nothing. The salvage gate reads it to tell work from talk (#1085).
   */
  mcpToolCalls: number;
  serverExitCause: ServerHandle["exitCause"];
  serverStderr: ServerHandle["recentStderr"];
  diagnostic: AgentDiagnostic;
}

export function createRunState(launch: OpencodeLaunch, sessionID: string): RunState {
  // bind the orchestrator label up front. without this, the first foreign
  // sessionID we see (a subagent) would consume the ORCHESTRATOR slot in the
  // labeler's FIFO and every label downstream would shift.
  const labeler = new SessionLabeler();
  labeler.labelFor(sessionID);
  return {
    label: "Pullfrog",
    orchestratorSessionID: sessionID,
    labeler,
    todoTracker: launch.ctx.todoTracker,
    onToolUse: launch.ctx.onToolUse,
    currentTurn: null,
    eventCount: 0,
    lastEventAt: performance.now(),
    taskDispatchByCallID: new Map(),
    reserveNoteArmed: false,
    childStartedAt: new Map(),
    loggedToolCallIDs: new Set(),
    mcpToolCalls: 0,
    serverExitCause: launch.server.exitCause,
    serverStderr: launch.server.recentStderr,
    diagnostic: launch.diagnostic,
  };
}

/** a finished tool call, in the shape both generations' events reduce to. */
export interface ToolCall {
  tool: string;
  callID: string;
  sessionID: string;
  input: unknown;
  /** the tool's output on success; undefined when it failed. */
  output: string | undefined;
  error: string | undefined;
}

/** bind an orchestrator subagent dispatch to a label, so its child session's logs carry it. */
export function recordSubagentDispatch(
  state: RunState,
  params: { callID: string; input: TaskDispatchInput }
): void {
  if (state.taskDispatchByCallID.has(params.callID)) return;
  const label = state.labeler.recordTaskDispatch(params.input);
  state.taskDispatchByCallID.set(params.callID, {
    label,
    startedAt: performance.now(),
    subagentType: params.input.subagent_type,
  });
  log.info(
    `» dispatching subagent: ${label}` +
      (params.input.subagent_type ? ` (subagent_type=${params.input.subagent_type})` : "")
  );
}

/** bind an in-flight dispatch to the child session doing its work: the lens budget's clock and handle. */
export function bindSubagentSession(
  state: RunState,
  params: { callID: string; sessionID: string }
): void {
  const dispatch = state.taskDispatchByCallID.get(params.callID);
  if (!dispatch || dispatch.child) return;
  const startedAt = state.childStartedAt.get(params.sessionID) ?? dispatch.startedAt;
  state.childStartedAt.set(params.sessionID, startedAt);
  dispatch.child = { sessionID: params.sessionID, startedAt };
}

/**
 * Terminal bookkeeping for one tool call: the log line, the orchestrator-side
 * hooks (`onToolUse` → diff-coverage tracker; `todowrite` / `report_progress`
 * → todo tracker), the MCP count, and the subagent-finish summary.
 *
 * `loggedToolCallIDs` is the dedup guard, so a driver that replays the
 * transcript at turn end (V1's SSE-connect race) fires each call's side
 * effects exactly once. critical for diff-coverage: a missed first-turn `read`
 * would make the `create_pull_request_review` pre-flight reject the review.
 */
export function recordToolCall(state: RunState, call: ToolCall): void {
  const isOrchestrator = call.sessionID === state.orchestratorSessionID;
  if (isOrchestrator && state.loggedToolCallIDs.has(call.callID)) return;
  const label = state.labeler.labelFor(call.sessionID);

  const inputFormatted = formatJsonValue(call.input ?? {});
  log.info(
    withLabel(
      label,
      inputFormatted !== "{}" ? `» ${call.tool}(${inputFormatted})` : `» ${call.tool}()`
    )
  );
  if (isOrchestrator) state.loggedToolCallIDs.add(call.callID);
  if (isOrchestrator && call.tool.startsWith("pullfrog_")) state.mcpToolCalls++;

  if (call.error === undefined) log.debug(withLabel(label, `  output: ${call.output}`));
  else log.info(withLabel(label, `» tool call failed: ${call.error}`));

  const dispatch = state.taskDispatchByCallID.get(call.callID);
  if (dispatch) {
    const dur = ((performance.now() - dispatch.startedAt) / 1000).toFixed(1);
    const output = call.output ?? "";
    const preview = output.length > 120 ? `${output.slice(0, 120)}…` : output;
    log.info(
      `» subagent finished: ${dispatch.label} (${dur}s, status=${call.error === undefined ? "completed" : "error"})` +
        (preview ? ` — ${preview.replace(/\n/g, " ")}` : "")
    );
    state.taskDispatchByCallID.delete(call.callID);
  }

  // subagent tool calls don't count toward the parent's diff-coverage
  // tracking — it's the orchestrator that submits the review.
  if (isOrchestrator) state.onToolUse?.({ toolName: call.tool, input: call.input });

  if (call.tool.includes("report_progress") && state.todoTracker) {
    log.debug("» report_progress detected, disabling todo tracking");
    state.todoTracker.cancel();
  }
  if (call.tool === "todowrite" && state.todoTracker?.enabled && isOrchestrator) {
    state.todoTracker.update(call.input);
  }
}

/** a finished text block. only the orchestrator's becomes the turn's output. */
export function recordText(state: RunState, params: { sessionID: string; text: string }): void {
  const text = params.text.trim();
  if (!text) return;
  const label = state.labeler.labelFor(params.sessionID);
  log.box(text, {
    title: label === ORCHESTRATOR_LABEL ? state.label : `${state.label} [${label}]`,
  });
  if (params.sessionID === state.orchestratorSessionID && state.currentTurn) {
    state.currentTurn.finalText = text;
  }
}

export function recordReasoning(
  state: RunState,
  params: { sessionID: string; text: string; duration: string }
): void {
  const text = params.text.trim();
  if (!text) return;
  const label = state.labeler.labelFor(params.sessionID);
  const preview = text.length > 280 ? `${text.slice(0, 280)}…` : text;
  log.info(withLabel(label, `» thinking${params.duration}: ${preview.replace(/\n+/g, " ")}`));
  if (text.length > 280) log.debug(withLabel(label, `» thinking (full): ${text}`));
}

/**
 * Land a configured effort on the variants the server declares for a model the
 * catalog has no alias for. it is the same `variants` list opencode's request
 * path indexes, so the rung is one it can apply; a model it does not list gets
 * no flag. see wiki/effort.md.
 */
export function declaredRung(
  launch: OpencodeLaunch,
  params: { name: string; variants: readonly string[] }
): string | undefined {
  const rung = resolveRunEffort({
    ...launch.ctx,
    resolvedModel: launch.rawModel,
    declaredVariants: params.variants,
  }).rung;
  log.info(
    `» effort: ${rung ? `${rung} (declared by opencode for ${params.name})` : `not applied — opencode declares no variants for ${params.name}`}`
  );
  return rung;
}

export function logTurnEnd(
  state: RunState,
  params: { durationMs: number; usage: AgentUsage | undefined }
): void {
  log.info(`» ${state.label} turn completed in ${Math.round(params.durationMs)}ms`);
  const usage = params.usage;
  if (!usage) return;
  logTokenTable({
    input: usage.inputTokens - (usage.cacheReadTokens ?? 0) - (usage.cacheWriteTokens ?? 0),
    cacheRead: usage.cacheReadTokens ?? 0,
    cacheWrite: usage.cacheWriteTokens ?? 0,
    output: usage.outputTokens,
    costUsd: usage.costUsd,
  });
}

/**
 * Why the turn ended without the model's work: the server dying outranks a
 * provider error, since it is why every later request failed.
 */
export function stallCause(state: RunState): string | undefined {
  return state.serverExitCause() ?? state.diagnostic.lastProviderError;
}

/**
 * Append the server's own stderr to a fatal harness error.
 *
 * opencode answers anything it cannot type as a bare 500 `UnknownError` plus a log
 * `ref` we have no way to resolve, so `--print-logs` (see `bootOpencodeServer`) is
 * what puts the underlying cause within reach at all.
 */
export function withServerStderr(message: string, recentStderr: readonly string[]): string {
  const tail = recentStderr.join("\n").trim();
  return tail ? `${message}\nopencode server stderr:\n${tail}` : message;
}

/** prefixed `activity timeout` so `renderRunError` routes the stall through the hang renderer. */
export function stallError(params: { what: string; cause: string | undefined }): string {
  return `activity timeout: ${params.what}${params.cause ? ` — likely cause: ${params.cause}` : ""}`;
}

/**
 * A turn that resolved with no model output, no text and no usage is the same
 * provider-silence condition `AGENT_FIRST_EVENT_TIMEOUT_MS` exists for — it
 * just RETURNED instead of hanging. calling it a success spent another gate
 * retry on the same mute provider and then told the customer the agent
 * "completed without reporting progress", which blames the one party that
 * never got a turn. see #1161.
 */
export function emptyTurn(state: RunState, result: AgentResult): AgentResult | undefined {
  if (state.diagnostic.sawModelOutput || result.output?.trim() || result.usage) return undefined;
  return {
    success: false,
    output: result.output,
    error: stallError({
      what: "the provider returned an empty turn — no model output, no tool calls, no usage",
      cause: stallCause(state),
    }),
    usage: result.usage,
  };
}

export function withLabel(label: string, message: string): string {
  return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
}