Repository Public
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