SPB Git

Repository Public

control.ts 5.7 KB 103 lines typescript

1/**2 * Control tools, engine side: `ask_owner`, `propose_criteria`, `update_criteria`, `finish`.3 * The tool package only validates their input; the semantics live here.4 */5import type { ResultEnvelope, ToolResult } from '@aos/shared';6import type { AskOwnerPayload, ControlPayload, FinishPayload, ProposeCriteriaPayload, UpdateCriteriaPayload } from '@aos/tools';7import { applyCriteriaUpdate, criteriaFromTexts, gateFinish, proposalQuestion, readProposalAnswer, storeCriteria } from './criteria.js';8import { formatCriteria } from './prompt.js';9import { isExpired, ownerWait, toolResult, type RunState } from './state.js';1011type Call = { id: string; name: string; input: unknown };12export interface ControlOutcome {13  result: ToolResult;14  /** Set when an accepted `finish` ends the run. */15  finish?: { envelope: ResultEnvelope; payload: FinishPayload };16  progress?: boolean;17}18interface Recorded {19  ok: boolean;20  content: string;21  criteria?: import('@aos/shared').DoneCriterion[];22  changed?: boolean;23}2425const answerOf = (r: unknown): string => {26  const a = (r as { answer?: unknown } | null)?.answer;27  return typeof a === 'string' ? a : '';28};2930async function askOwner(st: RunState, call: Call, p: AskOwnerPayload): Promise<ControlOutcome> {31  const payload: Record<string, unknown> = { text: p.question, source: 'ask_owner' };32  if (p.options.length > 0) payload.options = p.options;33  const r = await ownerWait<unknown>(st, 'question', payload);34  if (isExpired(r)) return { result: toolResult(call, false, 'The question expired without an answer from the owner.', { trust: 'system' }) };35  const answer = answerOf(r).trim();36  return { result: toolResult(call, true, answer.length > 0 ? `The owner answered:\n${answer}` : 'The owner replied with an empty answer.', { trust: 'owner' }), progress: true };37}3839async function proposeCriteria(st: RunState, call: Call, p: ProposeCriteriaPayload): Promise<ControlOutcome> {40  if (st.criteriaConfirmed) {41    return { result: toolResult(call, false, `The done criteria are already confirmed and cannot be replaced:\n${formatCriteria(st.criteria)}\nIf one of them is wrong, use ask_owner.`, { trust: 'system' }) };42  }43  const r = await ownerWait<unknown>(st, 'question', proposalQuestion(p.criteria, p.rationale));44  if (isExpired(r)) return { result: toolResult(call, false, 'The proposal expired without an answer from the owner.', { trust: 'system' }) };45  const reply = readProposalAnswer(answerOf(r));46  if (!reply.accepted) {47    const text = `The owner did not accept the criteria as proposed and replied:\n${reply.note ?? '(empty reply)'}\n\nRevise the criteria accordingly and call propose_criteria again. Writes stay refused until a proposal is accepted.`;48    return { result: toolResult(call, true, text, { trust: 'owner' }), progress: true };49  }50  const next = criteriaFromTexts(p.criteria);51  const content = `The owner confirmed the done criteria:\n${formatCriteria(next)}${reply.note ? `\n\nOwner's note: ${reply.note}` : ''}\n\nYou may now modify the workspace. Record each criterion with update_criteria once verified.`;52  const rec = await st.ctx.step<Record<string, unknown>, Recorded>(53    { kind: 'tool_call', name: 'propose_criteria', effect: 'idempotent_write', request: { name: call.name, input: call.input } },54    async () => {55      await storeCriteria(st.deps.kernel, st.ctx.missionId, next);56      return { result: { ok: true, content, criteria: next, changed: true } };57    },58  );59  st.criteria = rec.criteria ?? next;60  st.criteriaConfirmed = true;61  await st.ctx.emit('system.notice', { code: 'criteria', message: 'Done criteria confirmed by the owner', doneCriteria: st.criteria }, 'system');62  return { result: toolResult(call, true, rec.content, { trust: 'owner' }), progress: true };63}6465async function updateCriteria(st: RunState, call: Call, p: UpdateCriteriaPayload): Promise<ControlOutcome> {66  const u = applyCriteriaUpdate(st.criteriaConfirmed ? st.criteria : [], p);67  if (!u.ok) return { result: toolResult(call, false, u.content, { trust: 'system' }) };68  const rec = await st.ctx.step<Record<string, unknown>, Recorded>(69    { kind: 'tool_call', name: 'update_criteria', effect: 'idempotent_write', request: { name: call.name, input: call.input } },70    async () => {71      await storeCriteria(st.deps.kernel, st.ctx.missionId, u.criteria);72      return { result: { ok: true, content: u.content, criteria: u.criteria, changed: u.changed } };73    },74  );75  st.criteria = rec.criteria ?? u.criteria;76  await st.ctx.emit('system.notice', { code: 'criteria', message: 'Done criteria updated', doneCriteria: st.criteria }, 'system');77  return { result: toolResult(call, true, rec.content, { trust: 'system' }), progress: rec.changed === true };78}7980async function finish(st: RunState, call: Call, p: FinishPayload): Promise<ControlOutcome> {81  const gate = gateFinish(p, st.criteria, st.criteriaConfirmed);82  const content = gate.ok ? `Run ending with status "${p.status}".` : gate.error;83  const rec = await st.ctx.step<Record<string, unknown>, Recorded>(84    { kind: 'tool_call', name: 'finish', effect: 'pure', request: { name: call.name, input: call.input } },85    async () => ({ result: { ok: gate.ok, content } }),86  );87  const result = toolResult(call, rec.ok, rec.content, { trust: 'system' });88  return gate.ok ? { result, finish: { envelope: gate.envelope, payload: p } } : { result };89}9091export function runControl(st: RunState, call: Call, payload: ControlPayload): Promise<ControlOutcome> {92  switch (payload.kind) {93    case 'ask_owner':94      return askOwner(st, call, payload);95    case 'propose_criteria':96      return proposeCriteria(st, call, payload);97    case 'update_criteria':98      return updateCriteria(st, call, payload);99    case 'finish':100      return finish(st, call, payload);101  }102}103
Files main
Menu

Theme