Advertisement

← octto

src/tools/brainstorm.ts

14.6 KB Download file
import { tool } from "@opencode-ai/plugin/tool";

import { DEFAULT_ANSWER_TIMEOUT_MS } from "@/constants";
import type { Answer, SessionStore } from "@/session";
import { QUESTION_TYPES, QUESTIONS, STATUSES } from "@/session";
import { BRANCH_STATUSES, type BrainstormState, createStateStore, type StateStore } from "@/state";
import { generateSessionId } from "@/utils";

import { formatBranchStatus, formatFindings, formatFindingsList, formatQASummary } from "./formatters";
import { processAnswer } from "./processor";
import type { OcttoTool, OcttoTools, OpencodeClient } from "./types";

const MAX_ITERATIONS = 50;
const REVIEW_TIMEOUT_MS = 600_000;

const branchesSchema = tool.schema
  .array(
    tool.schema.object({
      id: tool.schema.string(),
      scope: tool.schema.string(),
      initial_question: tool.schema.object({
        type: tool.schema.enum(QUESTION_TYPES),
        config: tool.schema.looseObject({
          question: tool.schema.string().optional(),
          context: tool.schema.string().optional(),
        }),
      }),
    }),
  )
  .describe("Branches to explore");

/** Why answer collection stopped, so an unfinished session can explain itself to the agent. */
const COLLECTION_STOPS = {
  COMPLETE: "complete",
  NO_ANSWER_WITHIN_TIMEOUT: "no_answer_within_timeout",
  ITERATION_CAP: "iteration_cap",
  STALLED: "stalled",
} as const;

type CollectionStop = (typeof COLLECTION_STOPS)[keyof typeof COLLECTION_STOPS];

interface CollectionResult {
  state: BrainstormState | null;
  allComplete: boolean;
  stoppedBecause: CollectionStop;
}

function enqueueAnswerProcessing(
  stateStore: StateStore,
  sessions: SessionStore,
  sessionId: string,
  browserSessionId: string,
  questionId: string,
  response: Answer,
  client: OpencodeClient,
  pendingProcessing: Promise<void>[],
): void {
  const processing = processAnswer(
    stateStore,
    sessions,
    sessionId,
    browserSessionId,
    questionId,
    response,
    client,
  ).catch((error: unknown) => {
    console.error(`[octto] Error processing answer ${questionId}:`, error);
  });
  pendingProcessing.push(processing);
}

async function flushPending(pending: Promise<void>[]): Promise<void> {
  await Promise.all(pending);
  pending.length = 0;
}

interface PendingAnswer {
  completed: boolean;
  status?: string;
  question_id?: string;
  response?: unknown;
}

function isCompletedWithAnswer(
  answer: PendingAnswer,
): answer is PendingAnswer & { completed: true; question_id: string; response: Answer } {
  return answer.completed && typeof answer.question_id === "string" && answer.response !== undefined;
}

async function processOneAnswer(
  answer: PendingAnswer,
  pending: Promise<void>[],
  stateStore: StateStore,
  sessions: SessionStore,
  sessionId: string,
  browserSessionId: string,
  client: OpencodeClient,
): Promise<"continue" | "break" | "stalled"> {
  if (!answer.completed) {
    if (answer.status === STATUSES.NONE_PENDING) {
      // flushPending empties the array, so read it first.
      const mayStillPushQuestions = pending.length > 0;
      await flushPending(pending);
      return mayStillPushQuestions ? "continue" : "stalled";
    }
    return answer.status === STATUSES.TIMEOUT ? "break" : "continue";
  }

  if (isCompletedWithAnswer(answer)) {
    enqueueAnswerProcessing(
      stateStore,
      sessions,
      sessionId,
      browserSessionId,
      answer.question_id,
      answer.response,
      client,
      pending,
    );
  }
  return "continue";
}

async function collectAnswers(
  stateStore: StateStore,
  sessions: SessionStore,
  sessionId: string,
  browserSessionId: string,
  client: OpencodeClient,
): Promise<CollectionResult> {
  const pending: Promise<void>[] = [];
  let stoppedBecause: CollectionStop = COLLECTION_STOPS.ITERATION_CAP;

  for (let i = 0; i < MAX_ITERATIONS; i++) {
    if (await stateStore.isSessionComplete(sessionId)) {
      stoppedBecause = COLLECTION_STOPS.COMPLETE;
      break;
    }

    const answer = await sessions.getNextAnswer({
      session_id: browserSessionId,
      block: true,
      timeout: DEFAULT_ANSWER_TIMEOUT_MS,
    });

    const action = await processOneAnswer(answer, pending, stateStore, sessions, sessionId, browserSessionId, client);
    if (action === "break") {
      stoppedBecause = COLLECTION_STOPS.NO_ANSWER_WITHIN_TIMEOUT;
      break;
    }
    if (action === "stalled") {
      stoppedBecause = COLLECTION_STOPS.STALLED;
      break;
    }
  }

  await Promise.all(pending);

  const [state, allComplete] = await Promise.all([
    stateStore.getSession(sessionId),
    stateStore.isSessionComplete(sessionId),
  ]);

  return { state, allComplete, stoppedBecause };
}

interface ReviewSection {
  id: string;
  title: string;
  content: string;
}

function buildReviewSections(state: BrainstormState): ReviewSection[] {
  return [
    {
      id: "summary",
      title: "Original Request",
      content: state.request,
    },
    ...state.branch_order.map((id) => {
      const b = state.branches[id];
      const qaSummary = formatQASummary(b);
      return {
        id,
        title: b.scope,
        content: `**Finding:** ${b.finding || "No finding"}\n\n**Discussion:**\n${qaSummary || "(no questions answered)"}`,
      };
    }),
  ];
}

interface ReviewResult {
  approved: boolean;
  feedback: string;
}

function isReviewResponse(value: unknown): value is { decision: string; feedback?: string } {
  return typeof value === "object" && value !== null && "decision" in value;
}

async function waitForReviewApproval(sessions: SessionStore, browserSessionId: string): Promise<ReviewResult> {
  const reviewAnswer = await sessions.getNextAnswer({
    session_id: browserSessionId,
    block: true,
    timeout: REVIEW_TIMEOUT_MS,
  });

  if (!reviewAnswer.completed || !isReviewResponse(reviewAnswer.response)) {
    return { approved: false, feedback: "" };
  }

  return {
    approved: reviewAnswer.response.decision === "approve",
    feedback: reviewAnswer.response.feedback ?? "",
  };
}

function formatInProgressResult(state: BrainstormState, stoppedBecause: CollectionStop): string {
  const branches = state.branch_order.map((id) => formatBranchStatus(state.branches[id])).join("\n");
  return `<brainstorm_in_progress>
  <request>${state.request}</request>
  <branches>
${branches}
  </branches>
  <reason>${describeStop(stoppedBecause)}</reason>
  <next_action>${describeNextAction(stoppedBecause)}</next_action>
</brainstorm_in_progress>`;
}

function describeStop(stoppedBecause: CollectionStop): string {
  if (stoppedBecause === COLLECTION_STOPS.ITERATION_CAP) {
    return `Reached the ${MAX_ITERATIONS} iteration limit for one call.`;
  }
  if (stoppedBecause === COLLECTION_STOPS.STALLED) {
    return "No question is pending and no answer is being processed, so the session cannot progress on its own.";
  }
  return "No answer arrived within the wait window; the user is still thinking.";
}

function describeNextAction(stoppedBecause: CollectionStop): string {
  if (stoppedBecause === COLLECTION_STOPS.STALLED) {
    return "The session cannot progress on its own. Call end_brainstorm to close it out with the findings collected so far. Do not call await_brainstorm_complete again.";
  }
  return "The brainstorm is NOT finished and no branch was abandoned. Call await_brainstorm_complete again immediately with the same ids. Do not summarize, do not write the design document, and do not ask the user anything.";
}

function formatSkippedReviewResult(state: BrainstormState): string {
  return `<brainstorm_complete status="review_skipped">
  <request>${state.request}</request>
  <branch_count>${state.branch_order.length}</branch_count>
  <note>Browser session ended before review</note>
  ${formatFindings(state)}
  <next_action>Write the design document to docs/plans/</next_action>
</brainstorm_complete>`;
}

function formatCompletionResult(state: BrainstormState, approved: boolean, feedback: string): string {
  const feedbackXml = feedback ? `\n  <feedback>${feedback}</feedback>` : "";
  const nextAction = approved
    ? "Write the design document to docs/plans/"
    : "Review feedback and discuss with user before proceeding";
  return `<brainstorm_complete status="${approved ? "approved" : "changes_requested"}">
  <request>${state.request}</request>
  <branch_count>${state.branch_order.length}</branch_count>${feedbackXml}
  ${formatFindings(state)}
  <next_action>${nextAction}</next_action>
</brainstorm_complete>`;
}

interface BranchInput {
  id: string;
  scope: string;
  initial_question: {
    type: (typeof QUESTION_TYPES)[number];
    config: { question?: string; context?: string };
  };
}

async function registerBranchQuestions(
  store: StateStore,
  sessions: SessionStore,
  sessionId: string,
  branches: BranchInput[],
): Promise<{ session_id: string; url: string }> {
  const initialQuestions = branches.map((b) => {
    const { type, config } = b.initial_question;
    const context = `[${b.scope}] ${config.context ?? ""}`.trim();
    return { type, config: { ...config, context } };
  });

  const browserSession = await sessions.startSession({
    title: "Brainstorming Session",
    questions: initialQuestions,
  });

  await store.setBrowserSessionId(sessionId, browserSession.session_id);

  for (const [i, branch] of branches.entries()) {
    const questionId = browserSession.question_ids?.[i];
    if (!questionId) continue;
    const { type, config } = branch.initial_question;
    await store.addQuestionToBranch(sessionId, branch.id, {
      id: questionId,
      type,
      text: config.question ?? "Question",
      config,
    });
  }

  return browserSession;
}

function buildCreateBrainstormTool(store: StateStore, sessions: SessionStore): OcttoTool {
  return tool({
    description: "Create a new brainstorm session with exploration branches",
    args: {
      request: tool.schema.string().describe("The original user request"),
      branches: branchesSchema,
    },
    execute: async (args) => {
      const sessionId = generateSessionId();
      await store.createSession(
        sessionId,
        args.request,
        args.branches.map((b) => ({ id: b.id, scope: b.scope })),
      );
      const browserSession = await registerBranchQuestions(store, sessions, sessionId, args.branches);

      const branchesXml = args.branches.map((b) => `    <branch id="${b.id}">${b.scope}</branch>`).join("\n");
      return `<brainstorm_created>
  <session_id>${sessionId}</session_id>
  <browser_session>${browserSession.session_id}</browser_session>
  <url>${browserSession.url}</url>
  <branches>
${branchesXml}
  </branches>
  <next_action>Call await_brainstorm_complete(session_id="${sessionId}", browser_session_id="${browserSession.session_id}"). Do not loop on get_next_answer yourself: it does not record findings into branch state.</next_action>
</brainstorm_created>`;
    },
  });
}

function buildGetSessionSummaryTool(store: StateStore): OcttoTool {
  return tool({
    description: "Get summary of all branches and their findings",
    args: {
      session_id: tool.schema.string().describe("Brainstorm session ID"),
    },
    execute: async (args) => {
      const state = await store.getSession(args.session_id);
      if (!state) return `<error>Session not found: ${args.session_id}</error>`;

      const branches = state.branch_order.map((id) => formatBranchStatus(state.branches[id])).join("\n");
      const allDone = Object.values(state.branches).every((b) => b.status === BRANCH_STATUSES.DONE);

      return `<session_summary>
  <request>${state.request}</request>
  <status>${allDone ? "complete" : "in_progress"}</status>
  <branches>
${branches}
  </branches>
</session_summary>`;
    },
  });
}

function buildEndBrainstormTool(store: StateStore, sessions: SessionStore): OcttoTool {
  return tool({
    description: "End a brainstorm session and get final summary",
    args: {
      session_id: tool.schema.string().describe("Brainstorm session ID"),
    },
    execute: async (args) => {
      const state = await store.getSession(args.session_id);
      if (!state) return `<error>Session not found: ${args.session_id}</error>`;

      if (state.browser_session_id) {
        await sessions.endSession(state.browser_session_id);
      }

      const findings = formatFindingsList(state);
      await store.deleteSession(args.session_id);

      return `<brainstorm_ended>
  <request>${state.request}</request>
  ${findings}
  <next_action>Write the design document based on these findings</next_action>
</brainstorm_ended>`;
    },
  });
}

function buildAwaitBrainstormCompleteTool(
  store: StateStore,
  sessions: SessionStore,
  client: OpencodeClient,
): OcttoTool {
  return tool({
    description: `Wait for brainstorm session to complete. Processes answers asynchronously as they arrive.
Returns when all branches are done with their findings.
This is the recommended way to run a brainstorm - just create_brainstorm then await_brainstorm_complete.`,
    args: {
      session_id: tool.schema.string().describe("Brainstorm session ID (state session)"),
      browser_session_id: tool.schema.string().describe("Browser session ID (for collecting answers)"),
    },
    execute: async (args) => {
      const { state, allComplete, stoppedBecause } = await collectAnswers(
        store,
        sessions,
        args.session_id,
        args.browser_session_id,
        client,
      );

      if (!state) return "<error>Session lost</error>";
      if (!allComplete) return formatInProgressResult(state, stoppedBecause);

      const sections = buildReviewSections(state);

      try {
        sessions.pushQuestion(args.browser_session_id, QUESTIONS.SHOW_PLAN, {
          question: "Review Design Plan",
          sections,
        });
      } catch (_error: unknown) {
        // Expected when the browser session has already ended
        return formatSkippedReviewResult(state);
      }

      const { approved, feedback } = await waitForReviewApproval(sessions, args.browser_session_id);
      return formatCompletionResult(state, approved, feedback);
    },
  });
}

export function createBrainstormTools(sessions: SessionStore, client: OpencodeClient, baseDir?: string): OcttoTools {
  const store = createStateStore(baseDir);

  return {
    create_brainstorm: buildCreateBrainstormTool(store, sessions),
    get_session_summary: buildGetSessionSummaryTool(store),
    end_brainstorm: buildEndBrainstormTool(store, sessions),
    await_brainstorm_complete: buildAwaitBrainstormCompleteTool(store, sessions, client),
  };
}