/** * Protocol driver core. * * The session lifecycle that both adapters need: establish a handshake, run one * ROUND, gate the reply, decide between REPAIR / RESYNC / stop, and advance the * checkpoint. It knows nothing about pi and nothing about a browser -- everything * it cannot do itself arrives through a transport interface. * * Why extract it rather than let each entry point speak the protocol: * `index.ts` and `tools/cycle.mjs` both drive the same conversation. Two * implementations of the HELLO/ACK/ROUND/REPAIR/RESUME state machine would drift, * and that is the same "second source of truth" problem that the contract hash * already caught once. One core, two thin adapters. * * The second reason is testability. Gating a plan and driving a repair loop used * to be reachable only with a live browser and a logged-in account, so they were * covered by nothing. With the transport mocked, both paths are ordinary offline * tests. * * Zero dependencies beyond the sibling protocol modules. */ import { ERROR_CODES, advanceSession, buildRepair, faultDomain, MAX_REPAIRS, needsResync, newSession, parseAck, repairsExhausted, validateAck, type ErrorCode, type FaultDomain, type Session, } from "./handshake.ts"; import { RESPONSE_CONTRACT, buildTurnPrompt, checkReplyShape, composeRoundDynamic, parsePlan, validatePlanFatal, type Plan, } from "./protocol.ts"; import { planKey, type RoundKind, type RoundRecord } from "./cycle.ts"; import { readCopyVia } from "../browser/copySources.ts"; import type { PendingAction } from "./controlRegistry.ts"; /* ------------------------------------------------------------------ */ /* Transport */ /* ------------------------------------------------------------------ */ /** What the driver needs from a browser, in the smallest shape that works. */ export interface DriverTransport { /** * Send one message and return the reply plus the copied plan text. * * `copyVia` matters: a degraded copy means the driver cannot prove it read the * whole reply, and that changes the *attribution* of a failure even though it * does not change the decision to block execution. */ send(prompt: string): Promise<{ reply: string; planText: string | null; copyVia: string | null }>; /** Read recent protocol messages back out of the conversation. */ discover?(): Promise<{ session: { sid: string; round: number; prev: string; qid: string } | null; helloSent: boolean; ackSeen: boolean }>; } export interface DriverFacts { /** The user's original request. Sent in full exactly once, in ROUND 1. */ rootRequest: string; /** What the whole session is for. Static, sent once. */ objective: string; /** Repository context for this round. Recomputed each round, deliberately. */ digest: string; /** Per-round receipt derived from what pi actually did. */ receipt: string; } export interface DriverEvent { kind: "hello-sent" | "established" | "round-sent" | "gate-rejected" | "repair-sent" | "resync-needed" | "stopped"; detail: string; errors?: string[]; codes?: ErrorCode[]; } /* ------------------------------------------------------------------ */ /* Establish */ /* ------------------------------------------------------------------ */ export interface EstablishOptions { transport: DriverTransport; rootRequest: string; objective: string; contract?: string; tokenBudget?: number; /** Fixed sid, for tests and for resuming a known session. */ sid?: string; onEvent?: (event: DriverEvent) => void; } export interface EstablishResult { ok: boolean; session: Session | null; errors: string[]; /** The reply text, so a caller can show what came back instead of an ACK. */ reply: string; } /** * Run HELLO and validate the ACK. * * A half-established session is worse than none: pi would execute plans while * believing a contract is in force that the other side never agreed to. So every * condition must hold, and all of them are checked so the operator sees the * complete list rather than the first failure. */ export async function establish(options: EstablishOptions): Promise { const contract = options.contract ?? RESPONSE_CONTRACT; const session = newSession({ request: options.rootRequest, contract, sid: options.sid }); const hello = buildTurnPrompt({ mode: "hello", session, contract, objective: options.objective, tokenBudget: options.tokenBudget, }); options.onEvent?.({ kind: "hello-sent", detail: `sid=${session.sid} cv=${session.cvHash} cap=${session.capHash}` }); const { reply } = await options.transport.send(hello); const verdict = validateAck(session, parseAck(reply)); if (!verdict.ok) { options.onEvent?.({ kind: "stopped", detail: "握手未建立", errors: verdict.errors, codes: verdict.errorsCodes }); return { ok: false, session: null, errors: verdict.errors, reply }; } session.established = true; options.onEvent?.({ kind: "established", detail: `sid=${session.sid}` }); return { ok: true, session, errors: [], reply }; } /* ------------------------------------------------------------------ */ /* Plan gate */ /* ------------------------------------------------------------------ */ export type GateResult = | { ok: true; warnings: string[] } /** * Fatal, and the fault is the other side's: the reply is decodable and * malformed, so a REPAIR is the right response. */ | { ok: false; domain: "remote"; codes: ErrorCode[]; errors: string[] } /** * Fatal, but pi's own capture is at fault: the reply may be perfectly fine and * pi cannot prove it read all of it. Blocking execution is still correct; * sending a REPAIR is not, because that would blame the other side for a * message that was never malformed. */ | { ok: false; domain: "local"; codes: ErrorCode[]; errors: string[] } /** Version or contract mismatch: reformatting cannot fix it, RESYNC can. */ | { ok: false; domain: "version"; codes: ErrorCode[]; errors: string[] }; export interface GateInput { plan: Plan | null; rawText: string | null; /** * How the transport obtained `rawText` (see copySources.readCopyVia). It carries two * facts this gate cannot recover from the text: whether the capture was degraded, and * whether the text is a code block's body rather than the reply's own Markdown. */ copyVia: string | null; session: Pick; } /** * Decide whether a reply may be executed. * * Three kinds of failure, three different responses. Collapsing them was the * defect the adjudication caught: "do not execute" and "the other side is at * fault" are separate judgements. * * The dangerous option is never "warn and continue". Executing a plan you are not * certain you fully read corrupts FRAME, planKey and checkpoint credibility -- * the recovery chain itself -- so a degraded copy blocks execution even though * nothing is wrong with the reply. * * Attribution is decided before content: a degraded capture is checked for * completeness first, so a block-count mismatch can never be blamed on the other side * when the text itself came out of the DOM unverified (BUG-015). What remains is judged * on the reply's own Markdown, except for the one rule a code-source capture cannot * observe. */ export function gatePlan(input: GateInput): GateResult { if (!input.plan || !input.rawText) { return { ok: false, domain: "local", codes: [ERROR_CODES.SOURCE_UNCERTAIN], errors: ["没有取到方案文本"], }; } const shape = checkReplyShape(input.rawText); // What the capture WAS decides how a mismatch is attributed, so it is read once, from the // transport's own metadata, before any judgement about the text (BUG-015). const capture = readCopyVia(input.copyVia); if (capture.degraded && !shape.conforms) { return { ok: false, domain: "local", codes: [ERROR_CODES.CAPTURE], errors: [`复制退化为 ${input.copyVia},无法证明回复完整(代码块数 ${shape.codeBlocks})`], }; } // A code-source capture returns that block's body, so the reply's Markdown fence shape is // not observable through it: the affordance decides whether fences come along. Judging the // one-block rule on such text is what turned a legitimate capture into `E_BLOCKS: remote` // and sent REPAIR after REPAIR for a message that was never malformed (BUG-015). The // exemption is granted by the source metadata, never by the text looking like code, and it // covers that one rule only -- header, FRAME identity, DECISION and TASK still have to hold. const blocksApplicable = !capture.codeSource; const report = validatePlanFatal(input.plan, input.session, shape, { blocksApplicable }); if (report.fatalErrors.length === 0) return { ok: true, warnings: report.warnings }; const domains = report.codes.map((code) => faultDomain(code)); const domain: FaultDomain = domains.includes("local") ? "local" : domains.includes("version") ? "version" : "remote"; return { ok: false, domain, codes: report.codes, errors: report.fatalErrors }; } /* ------------------------------------------------------------------ */ /* One round */ /* ------------------------------------------------------------------ */ export type RoundOutcome = /** A gate-passing plan. The caller decides whether to execute it. */ | { kind: "plan"; plan: Plan; raw: string; warnings: string[] } /** Not an error: the driver wants the caller to stop, with a reason. */ | { kind: "stopped"; reason: string } /** The round could not produce a usable plan and retrying will not help. */ | { kind: "failed"; reason: string }; export interface RunRoundOptions { transport: DriverTransport; session: Session; facts: DriverFacts; /** Repairs already used this session; the budget is global, not per round. */ repairs: number; contract?: string; capabilities?: readonly string[]; denials?: readonly string[]; promptTokens?: number; onEvent?: (event: DriverEvent) => void; } export interface RunRoundResult { outcome: RoundOutcome; session: Session; repairs: number; /** Round record for this round, to be appended by the caller. */ record: RoundRecord | null; } /** * Send one ROUND and gate the reply, repairing when that is warranted. * * The repair loop lives here rather than in an adapter so both entry points get * the same behaviour: same round number, same `prev`, nothing executed, bounded * by MAX_REPAIRS, and a version mismatch routed to RESYNC instead of a pointless * reformat request. */ export async function runRound(options: RunRoundOptions): Promise { const contract = options.contract ?? RESPONSE_CONTRACT; let repairs = options.repairs; let attempt = 0; const prompt = buildTurnPrompt({ mode: "round", session: options.session, frame: { sid: options.session.sid, round: options.session.round, prev: options.session.prev, qid: options.session.qid, }, contract, objective: options.facts.objective, dynamic: composeRoundDynamic({ receipt: options.facts.receipt, digest: options.facts.digest }), rootRequest: options.facts.rootRequest, rootRequestAlreadySent: options.session.round > 1, tokenBudget: options.promptTokens, }); options.onEvent?.({ kind: "round-sent", detail: `r=${options.session.round} p=${options.session.prev}`, }); let response = await options.transport.send(prompt); for (;;) { const plan = response.planText ? parsePlan(response.planText) : null; const gated = gatePlan({ plan, rawText: response.planText, copyVia: response.copyVia, session: options.session, }); if (gated.ok) { return { outcome: { kind: "plan", plan: plan as Plan, raw: response.planText as string, warnings: gated.warnings }, session: options.session, repairs, record: null, }; } options.onEvent?.({ kind: "gate-rejected", detail: `${gated.domain}: ${gated.errors.join(";")}`, errors: gated.errors, codes: gated.codes, }); if (gated.domain === "local") { // pi's own capture failed. Blocking execution is right; blaming the // other side is not. return { outcome: { kind: "failed", reason: `本地采集失败(${gated.codes.join(",")}):${gated.errors.join(";")}。` + "归因于 pi 侧读取,不发 REPAIR;检查复制路径或改用 copyMode=turn。", }, session: options.session, repairs, record: null, }; } if (gated.domain === "version" || gated.codes.some((code) => needsResync(code))) { return { outcome: { kind: "stopped", reason: `版本或契约不一致(${gated.codes.join(",")}):${gated.errors.join(";")}。需要 RESYNC,而不是重发格式。`, }, session: options.session, repairs, record: null, }; } repairs += 1; if (repairsExhausted(repairs)) { return { outcome: { kind: "stopped", reason: `协议违规(repair 已达上限 ${MAX_REPAIRS}):${gated.errors.join(";")}`, }, session: options.session, repairs, record: null, }; } attempt += 1; options.onEvent?.({ kind: "repair-sent", detail: `#${attempt} ${gated.codes.join(",")}`, errors: gated.errors, codes: gated.codes, }); // Read the REPAIR's own reply. Re-sending the ROUND here would waste a // round and, worse, would let the other side answer a different question // than the one REPAIR asked. response = await options.transport.send( buildRepair( { sid: options.session.sid, round: options.session.round, prev: options.session.prev, qid: options.session.qid, }, gated.codes, ), ); } } /* ------------------------------------------------------------------ */ /* Settle */ /* ------------------------------------------------------------------ */ /** Which kind of round this was, from the other side's own decision. */ export function roundKindOf(decision: Plan["decision"] | undefined): RoundKind { if (decision === "need_info") return "info"; if (decision === "done" || decision === "blocked") return "terminal"; return "work"; } export interface SettleInput { session: Session; plan: Plan; facts: { changedFiles: string[]; ok: boolean; pushed: boolean | null }; index: number; startedAt: number; } /** * Advance the session after a plan has been validated AND executed AND settled. * * This is the only place `prev` and `checkpoint` may move, which is what makes the * next ROUND's `prev=` the remote confirmation of this one. A PLAN reply by itself * proves only that the plan was written, never that it ran. */ export function settle(input: SettleInput): { session: Session; record: RoundRecord } { const key = planKey(input.plan); const session = advanceSession(input.session, key, input.plan.decision); return { session, record: { index: input.index, startedAt: input.startedAt, roundKind: roundKindOf(input.plan.decision), decision: input.plan.decision, taskId: input.plan.taskId, changedFiles: input.facts.changedFiles, planKey: key, ok: input.facts.ok, pushed: input.facts.pushed, failure: input.facts.ok ? undefined : "round failed", }, }; } /** One-line description for status output and logs. */ export function describeOutcome(outcome: RoundOutcome): string { switch (outcome.kind) { case "plan": return `plan decision=${outcome.plan.decision} task=${outcome.plan.taskId ?? "-"} steps=${outcome.plan.steps.length}`; case "stopped": return `stopped: ${outcome.reason}`; case "failed": return `failed: ${outcome.reason}`; } } /** Re-exported so an adapter needs only one import site for the driver surface. */ export type { PendingAction };