Z ZPI 文档 v0.3.8
介绍页

源码

packages/builtin-plugins/src/questions.ts

typescript214 行查看原文
// AskUserQuestion schema and answer semantics follow ZCode (Apache-2.0).

import type { ToolDefinition } from "@zpi-dev/coding-agent";
import { Type } from "typebox";
import { Value } from "typebox/value";
import type { UserQuestionRequest, UserQuestionResponse } from "./types.ts";

const parameters = Type.Object(
  {
    questions: Type.Array(
      Type.Object(
        {
          question: Type.String({ minLength: 1, maxLength: 8000 }),
          header: Type.String({ minLength: 1, maxLength: 12 }),
          options: Type.Array(
            Type.Object(
              {
                label: Type.String({ minLength: 1, maxLength: 200 }),
                description: Type.String({ maxLength: 8000 }),
                preview: Type.Optional(Type.String({ maxLength: 16000 })),
              },
              { additionalProperties: false },
            ),
            { minItems: 2, maxItems: 4 },
          ),
          multiSelect: Type.Boolean(),
        },
        { additionalProperties: false },
      ),
      { minItems: 1, maxItems: 4 },
    ),
  },
  { additionalProperties: false },
);

interface PendingQuestion {
  request: UserQuestionRequest;
  resolve: (response: UserQuestionResponse) => void;
  timers: ReturnType<typeof setTimeout>[];
  start: () => void;
  signal?: AbortSignal;
}
export class UserQuestions {
  private pending = new Map<string, PendingQuestion[]>();
  private publish: (sessionId: string, request: UserQuestionRequest | null) => void;
  constructor(publish: (sessionId: string, request: UserQuestionRequest | null) => void) {
    this.publish = publish;
  }
  get(sessionId: string) {
    return this.pending.get(sessionId)?.[0]?.request ?? null;
  }
  snooze(sessionId: string, requestId: string) {
    const pending = this.pending.get(sessionId)?.[0];
    const state = pending?.request.autoResolution;
    if (!pending || pending.request.id !== requestId || !state || state.phase === "snoozed") return;
    for (const timer of pending.timers) clearTimeout(timer);
    pending.timers = [];
    pending.request = {
      ...pending.request,
      autoResolution: { phase: "snoozed", startedAt: state.startedAt, snoozedAt: Date.now() },
    };
    this.publish(sessionId, pending.request);
  }
  respond(sessionId: string, requestId: string, response: UserQuestionResponse) {
    const pending = this.pending.get(sessionId)?.[0];
    if (!pending || pending.request.id !== requestId) throw new Error("not_found: 提问已结束");
    if (!response?.answers || typeof response.answers !== "object" || Array.isArray(response.answers))
      throw new Error("invalid_input: 无效回答");
    const questions = new Set(pending.request.questions.map((q) => q.question));
    if (
      Object.entries(response.answers).some(
        ([key, value]) => !questions.has(key) || typeof value !== "string" || value.length > 16000,
      )
    )
      throw new Error("invalid_input: 无效回答");
    if (
      response.annotations &&
      (typeof response.annotations !== "object" ||
        Array.isArray(response.annotations) ||
        Object.entries(response.annotations).some(
          ([key, value]) =>
            !questions.has(key) ||
            !value ||
            typeof value !== "object" ||
            Array.isArray(value) ||
            Object.entries(value).some(
              ([field, text]) =>
                !["preview", "notes"].includes(field) || typeof text !== "string" || text.length > 16000,
            ),
        ))
    )
      throw new Error("invalid_input: 无效回答备注");
    pending.resolve(structuredClone(response));
  }
  tool(sessionId: string): ToolDefinition<typeof parameters> {
    return {
      name: "AskUserQuestion",
      label: "提问",
      description: [
        "Use this tool only when blocked on a decision that is the user's to make and cannot be resolved from the request, code, or sensible defaults.",
        "Ask 1-4 questions with 2-4 options each. Users can always provide custom text. Do not include an Other option.",
        "Use multiSelect: true for multiple choices. Put a recommended option first and suffix its label with (Recommended).",
        "Use optional preview text for concrete artifacts, code or Markdown mockups to compare; previews apply to single-select questions.",
        "The tool waits for user answers. Skipped questions have no answer; continue using best judgment without inventing preferences.",
      ].join("\n"),
      parameters,
      executionMode: "parallel",
      requiresUserInteraction: true,
      prepareArguments: (args) => {
        const value = structuredClone(args) as { questions?: { multiSelect?: boolean }[] };
        // ZCode defaults omitted multiSelect to false while advertising it as required to providers.
        if (Array.isArray(value?.questions))
          for (const question of value.questions) question.multiSelect ??= false;
        if (!Value.Check(parameters, value)) throw new Error("Invalid AskUserQuestion arguments");
        const parsed = value as unknown as Pick<UserQuestionRequest, "questions">;
        if (new Set(parsed.questions.map((q) => q.question)).size !== parsed.questions.length)
          throw new Error("Question texts must be unique");
        for (const question of parsed.questions) {
          if (
            new Set(question.options.map((option) => option.label)).size !== question.options.length ||
            question.options.some((option) => option.label.trim().toLowerCase() === "other")
          )
            throw new Error("Option labels must be unique and must not include Other");
        }
        return parsed;
      },
      execute: async (id, params, signal) => {
        if (signal?.aborted) throw new Error("AskUserQuestion cancelled");

        const request: UserQuestionRequest = {
          id,
          questions: params.questions,
        };
        let cancel = () => {};
        let autoResolved = false;
        let pending: PendingQuestion | undefined;
        try {
          const response = await new Promise<UserQuestionResponse>((resolve, reject) => {
            cancel = () => reject(new Error("AskUserQuestion cancelled"));
            const entry: PendingQuestion = {
              request,
              resolve,
              timers: [],
              signal,
              start: () => {
                const startedAt = Date.now();
                entry.request = {
                  ...entry.request,
                  autoResolution: {
                    phase: "hiddenGrace",
                    startedAt,
                    visibleAt: startedAt + 60_000,
                    deadlineAt: startedAt + 300_000,
                  },
                };
                entry.timers.push(
                  setTimeout(() => {
                    const state = entry.request.autoResolution;
                    if (!state || state.phase === "snoozed") return;
                    entry.request = {
                      ...entry.request,
                      autoResolution: { ...state, phase: "visibleCountdown" },
                    };
                    this.publish(sessionId, entry.request);
                  }, 60_000),
                  setTimeout(() => {
                    autoResolved = true;
                    entry.resolve({ answers: {} });
                  }, 300_000),
                );
                this.publish(sessionId, entry.request);
              },
            };
            pending = entry;
            const queue = this.pending.get(sessionId) ?? [];
            queue.push(entry);
            this.pending.set(sessionId, queue);
            signal?.addEventListener("abort", cancel, { once: true });
            if (queue.length === 1) entry.start();
          });
          const count = Object.keys(response.answers).length;
          const text = count
            ? `User answers: ${JSON.stringify(response)}. Use best judgment for any skipped questions.`
            : `The user did not provide answers${autoResolved ? " within five minutes" : ""}. Continue using your best judgment; do not treat this as rejection or invent a preference.`;
          return {
            content: [{ type: "text", text }],
            details: JSON.parse(
              JSON.stringify({
                questions: params.questions,
                ...response,
                ...(autoResolved ? { autoResolved: true } : {}),
              }),
            ),
          };
        } finally {
          signal?.removeEventListener("abort", cancel);
          for (const timer of pending?.timers ?? []) clearTimeout(timer);
          const queue = this.pending.get(sessionId);
          const index = pending && queue ? queue.indexOf(pending) : -1;
          if (queue && index >= 0) {
            queue.splice(index, 1);
            if (!queue.length) {
              this.pending.delete(sessionId);
              this.publish(sessionId, null);
            } else if (index === 0 && !queue[0].signal?.aborted) {
              queue[0].start();
            }
          }
        }
      },
    };
  }
}