SPB Git

Repository Public

control.ts 9.8 KB 176 lines typescript

1/**2 * Control tools: `ask_owner`, `propose_criteria`, `update_criteria`, `finish`.3 * Their `run` only validates and returns a structured marker (`ToolRunResult.control`); the ENGINE implements the4 * semantics (durable question wait, criteria state, finish gating, ResultEnvelope). The `output` string of a5 * control tool is a placeholder — the engine replaces it with the real outcome (owner's answer, gate error…).6 */7import { z } from 'zod';8import type { ResultEnvelope } from '@aos/shared';9import { opt, toStrictJsonSchema } from './schema.js';10import type { Tool, ToolRunResult } from './types.js';1112export const CONTROL_TOOL_NAMES = ['ask_owner', 'propose_criteria', 'update_criteria', 'finish'] as const;13export type ControlToolName = (typeof CONTROL_TOOL_NAMES)[number];14export function isControlTool(name: string): name is ControlToolName {15  return (CONTROL_TOOL_NAMES as readonly string[]).includes(name);16}1718// ---- inputs ------------------------------------------------------------------------------------------------1920export const AskOwnerInput = z.object({21  question: z.string().min(1).max(4000).describe('The question, self-contained: the owner may read it days later on a phone without the conversation.'),22  options: opt(z.array(z.string().min(1).max(300)).max(8)).describe('Optional short answer choices (the owner can still answer freely). null when open-ended.'),23});24export type AskOwnerInput = z.infer<typeof AskOwnerInput>;2526export const ProposeCriteriaInput = z.object({27  criteria: z.array(z.string().min(1).max(1000)).min(1).max(12).describe('3 to 8 verifiable done criteria, each checkable by a command, a test or a file inspection.'),28  rationale: opt(z.string().max(2000)).describe('One or two sentences on why these criteria cover the objective. null if obvious.'),29});30export type ProposeCriteriaInput = z.infer<typeof ProposeCriteriaInput>;3132export const UpdateCriteriaInput = z.object({33  updates: z34    .array(35      z.object({36        id: z.string().min(1).max(100).describe('Criterion id exactly as listed in the mission (e.g. "c1").'),37        passes: z.boolean().describe('true once verified; false to retract a previous pass.'),38        evidence: z.string().max(2000).describe('What proves it: the command run and its result, the test name, the file and line. Required (non-empty) when passes is true.'),39      }),40    )41    .min(1)42    .max(50),43}).superRefine((v, c) => {44  v.updates.forEach((u, i) => {45    if (u.passes && u.evidence.trim().length === 0) c.addIssue({ code: 'custom', path: ['updates', i, 'evidence'], message: 'evidence is required when passes is true' });46  });47  const ids = v.updates.map((u) => u.id);48  if (new Set(ids).size !== ids.length) c.addIssue({ code: 'custom', path: ['updates'], message: 'duplicate criterion id' });49});50export type UpdateCriteriaInput = z.infer<typeof UpdateCriteriaInput>;5152export const FinishStatus = z.enum(['done', 'partial', 'blocked', 'failed']);53export const FinishInput = z.object({54  status: FinishStatus.describe('done = every done criterion passes with evidence; partial = useful progress but not everything; blocked = cannot proceed without the owner or something external; failed = attempted and did not work.'),55  summary: z.string().min(1).max(8000).describe('What was done, what changed (files, commits), and the state the workspace is left in.'),56  reason: opt(z.string().max(2000)).describe('Required when status is not done: why the mission is not complete. null when done.'),57  evidence: z58    .array(59      z.object({60        criterion: opt(z.string().max(100)).describe('Criterion id this evidence supports, or null.'),61        kind: z.enum(['command', 'test', 'file', 'source', 'note']),62        detail: z.string().min(1).max(2000),63      }),64    )65    .max(100)66    .describe('Evidence backing the summary. May be empty for non-done statuses.'),67  openQuestions: z.array(z.string().min(1).max(1000)).max(20).describe('Questions or follow-ups for the owner. Empty array if none.'),68}).superRefine((v, c) => {69  if (v.status !== 'done' && !(v.reason && v.reason.trim().length > 0)) c.addIssue({ code: 'custom', path: ['reason'], message: 'reason is required when status is not "done"' });70});71export type FinishInput = z.infer<typeof FinishInput>;7273// ---- parsed payloads (what the engine consumes) -----------------------------------------------------------------7475export type AskOwnerPayload = { kind: 'ask_owner'; question: string; options: string[] };76export type ProposeCriteriaPayload = { kind: 'propose_criteria'; criteria: string[]; rationale: string | null };77export type UpdateCriteriaPayload = { kind: 'update_criteria'; updates: { id: string; passes: boolean; evidence: string }[] };78export type FinishPayload = {79  kind: 'finish';80  status: z.infer<typeof FinishStatus>;81  summary: string;82  reason: string | null;83  evidence: { criterion?: string; kind: 'command' | 'test' | 'file' | 'source' | 'note'; detail: string }[];84  openQuestions: string[];85};86export type ControlPayload = AskOwnerPayload | ProposeCriteriaPayload | UpdateCriteriaPayload | FinishPayload;8788/** Validate a control tool's raw input and return the typed payload (throws ZodError on invalid input). */89export function parseControl(name: ControlToolName, rawInput: unknown): ControlPayload {90  switch (name) {91    case 'ask_owner': {92      const v = AskOwnerInput.parse(rawInput);93      return { kind: 'ask_owner', question: v.question.trim(), options: v.options ?? [] };94    }95    case 'propose_criteria': {96      const v = ProposeCriteriaInput.parse(rawInput);97      return { kind: 'propose_criteria', criteria: v.criteria.map((s) => s.trim()), rationale: v.rationale?.trim() || null };98    }99    case 'update_criteria': {100      const v = UpdateCriteriaInput.parse(rawInput);101      return { kind: 'update_criteria', updates: v.updates.map((u) => ({ id: u.id, passes: u.passes, evidence: u.evidence.trim() })) };102    }103    case 'finish': {104      const v = FinishInput.parse(rawInput);105      return {106        kind: 'finish',107        status: v.status,108        summary: v.summary,109        reason: v.reason?.trim() || null,110        evidence: v.evidence.map((e) => (e.criterion ? { criterion: e.criterion, kind: e.kind, detail: e.detail } : { kind: e.kind, detail: e.detail })),111        openQuestions: v.openQuestions,112      };113    }114  }115}116117/** ResultEnvelope fields derived from a `finish` payload (the engine adds artifacts/payload and applies the done gate). */118export function finishToEnvelope(p: FinishPayload): ResultEnvelope {119  const summary = p.reason && p.status !== 'done' ? `${p.summary}\n\nReason: ${p.reason}`.slice(0, 8000) : p.summary;120  return { status: p.status, summary, payload: null, artifacts: [], evidence: p.evidence, openQuestions: p.openQuestions };121}122123function controlTool<I>(name: ControlToolName, description: string, input: z.ZodType<I>, placeholder: string): Tool<I> {124  return {125    def: { name, description, inputSchema: toStrictJsonSchema(input) },126    input,127    effect: 'pure',128    risk: 'read',129    parallelSafe: false,130    async run(raw): Promise<ToolRunResult> {131      let control: ControlPayload;132      try {133        control = parseControl(name, raw);134      } catch (e) {135        const msg = e instanceof z.ZodError ? e.issues.map((i) => `${i.path.join('.') || 'input'}: ${i.message}`).join('; ') : String(e);136        return { ok: false, output: `Invalid ${name} input: ${msg}` };137      }138      return { ok: true, output: JSON.stringify({ control: name, accepted: true, note: placeholder }), control, trust: 'system' };139    },140  };141}142143export const askOwnerTool = controlTool(144  'ask_owner',145  'Ask the project owner a question and pause until they answer (this can take hours or days; the run resumes with their answer as the tool result). ' +146    'Use it when you are blocked on a decision only the owner can make, when requirements are ambiguous in a way that changes the work, or before doing something risky that was not requested. ' +147    'Do not use it for things you can find out yourself by reading the workspace or running a command. Ask one precise, self-contained question; offer options when the choices are clear.',148  AskOwnerInput,149  'question recorded; the engine will deliver the answer',150);151152export const proposeCriteriaTool = controlTool(153  'propose_criteria',154  'Propose the done criteria for this mission when none were given. Call it once, before any write, after inspecting the workspace enough to make the criteria concrete. ' +155    'The owner confirms, edits or rejects them; the tool result contains the confirmed list with ids. Each criterion must be verifiable (a command that passes, a test, a file that exists with given content) — avoid vague wording like "works well".',156  ProposeCriteriaInput,157  'proposal recorded; the engine will ask the owner to confirm',158);159160export const updateCriteriaTool = controlTool(161  'update_criteria',162  'Mark done criteria as passing (or retract a pass) WITH evidence. Call it right after you have verified a criterion — e.g. the test command exited 0 — and quote that verification in `evidence`. ' +163    'You cannot change the text of a criterion or add new ones here; if a criterion is wrong or impossible, use ask_owner. `finish` with status "done" is refused until every criterion passes with evidence.',164  UpdateCriteriaInput,165  'update recorded',166);167168export const finishTool = controlTool(169  'finish',170  'End the run. This is the ONLY way to finish: a turn without tool calls does not end the mission. ' +171    'status "done" is accepted only when every done criterion has been marked passing with evidence via update_criteria; otherwise the call returns an error listing what is missing and the run continues. ' +172    '"partial", "blocked" and "failed" are always accepted and require `reason`. Commit your work before finishing. The summary is what the owner reads first: be factual and specific, do not claim anything you did not verify.',173  FinishInput,174  'finish requested; the engine applies the done-criteria gate',175);176
Files main
Menu

Theme