diff --git a/apps/agent-forge/server/author/proposal/__tests__/failures.test.ts b/apps/agent-forge/server/author/proposal/__tests__/failures.test.ts new file mode 100644 index 000000000..089016324 --- /dev/null +++ b/apps/agent-forge/server/author/proposal/__tests__/failures.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import type { AuthorToolFailure } from '../../../../shared/authorWire'; +import { + alreadySavedFailure, + ambiguousProposalFailure, + noProposalFailure, + staleProposalFailure, + unansweredFailure, + unknownProposalFailure, + wrongAgentFailure, +} from '../failures'; + +/** + * The save gate's copy contract (PLAN §8.3): every refusal carries a + * model-facing `error` and `next` (which may name tools and ids so the + * model can recover) and a person-facing `userMessage` in plain words - + * no camelCase tool names, no proposal ids (`p_`/`call_` prefixes). + */ + +const FORBIDDEN_IN_USER_COPY = /[a-z]+[A-Z]\w*|p_|call_/; + +const ALL: AuthorToolFailure[] = [ + unansweredFailure('create'), + unansweredFailure('update'), + noProposalFailure('create'), + noProposalFailure('update'), + alreadySavedFailure(), + unknownProposalFailure('create', 'call_p1'), + unknownProposalFailure('update', 'call_p1'), + staleProposalFailure('call_p1', 'call_p2'), + wrongAgentFailure('call_u1', 'agent-a', 'agent-b'), + ambiguousProposalFailure('create', 'call_p1'), + ambiguousProposalFailure('update', 'call_p1'), +]; + +describe('save-gate failure copy', () => { + it('every failure is a returned refusal with non-empty error, userMessage and next', () => { + for (const failure of ALL) { + expect(failure.ok).toBe(false); + expect(failure.error.length).toBeGreaterThan(0); + expect(failure.userMessage.length).toBeGreaterThan(0); + expect(failure.next?.length).toBeGreaterThan(0); + } + }); + + it('no userMessage contains a tool name or an id', () => { + for (const failure of ALL) { + expect(failure.userMessage).not.toMatch(FORBIDDEN_IN_USER_COPY); + } + }); + + it('each builder carries its code', () => { + expect(unansweredFailure('create').code).toBe('UNANSWERED'); + expect(noProposalFailure('create').code).toBe('NO_PROPOSAL'); + expect(alreadySavedFailure().code).toBe('ALREADY_SAVED'); + expect(unknownProposalFailure('create', 'call_p1').code).toBe('UNKNOWN_PROPOSAL'); + expect(staleProposalFailure('call_p1', 'call_p2').code).toBe('STALE_PROPOSAL'); + expect(wrongAgentFailure('call_u1', 'agent-a', 'agent-b').code).toBe('WRONG_AGENT'); + expect(ambiguousProposalFailure('create', 'call_p1').code).toBe('AMBIGUOUS_PROPOSAL'); + }); + + it('the model copy interpolates the proposal ids it names', () => { + expect(unknownProposalFailure('create', 'call_p1').error).toContain('"call_p1"'); + const stale = staleProposalFailure('call_p1', 'call_p2'); + expect(stale.error).toContain('"call_p1"'); + expect(stale.error).toContain('"call_p2"'); + expect(stale.latestProposalId).toBe('call_p2'); + expect(wrongAgentFailure('call_u1', 'agent-a', 'agent-b').error).toContain('"agent-a"'); + expect(wrongAgentFailure('call_u1', 'agent-a', 'agent-b').error).toContain('"agent-b"'); + expect(ambiguousProposalFailure('create', 'call_p1').error).toContain('"call_p1"'); + }); + + it('the next step names the kind\'s propose tool so the model knows what to call', () => { + expect(noProposalFailure('create').next).toContain('Call proposeAgent,'); + expect(noProposalFailure('update').next).toContain('Call proposeAgentUpdate,'); + expect(unknownProposalFailure('create', 'p1').next).toContain('proposeAgent result'); + expect(unknownProposalFailure('update', 'p1').next).toContain('proposeAgentUpdate result'); + expect(ambiguousProposalFailure('update', 'p1').next).toContain('Call proposeAgentUpdate again'); + }); +}); diff --git a/apps/agent-forge/server/author/proposal/__tests__/gate.test.ts b/apps/agent-forge/server/author/proposal/__tests__/gate.test.ts new file mode 100644 index 000000000..ec4ad3368 --- /dev/null +++ b/apps/agent-forge/server/author/proposal/__tests__/gate.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentSpecV2 } from 'lousho'; +import type { + AuthorAssistantMessage, + AuthorMessage, + AuthorToolFailure, + AuthorToolPart, + AuthorUserMessage, + ProposalOutput, +} from '../../../../shared/authorWire'; +import type { AuthorTurn } from '../../turnRunner'; +import { identityKey, proposalTurnState, type ProposalIdentity } from '../turnState'; +import { + identityOfProposal, + openProposalCards, + resolveStoredProposal, + saveBlockingError, + transcriptProposalStatus, +} from '../gate'; + +/** + * The AA10a save-gate table (one `it` per row): T is the stored transcript + * (ending at the previous turn), S is this turn's ProposalTurnState. The + * fixture builders mirror the ticket's `user`/`assistant`/`okPart` set + * plus `propose`/`proposeUpdate`/`saveEvent` conveniences. + */ + +const AT = '2026-01-01T00:00:00.000Z'; +const CREATE: ProposalIdentity = { kind: 'create' }; + +let seq = 0; + +function update(agentId: string): ProposalIdentity { + return { kind: 'update', agentId }; +} + +function user(text: string): AuthorUserMessage { + seq += 1; + return { id: `m-${seq}`, role: 'user', text, at: AT }; +} + +/** The app-written user message a card's Apply button appends. */ +function saveEvent(proposalId: string, agentId = 'agent-1'): AuthorUserMessage { + return { ...user(''), uiEvent: { kind: 'save', proposalId, agentId, version: 1 } }; +} + +function assistant(...parts: AuthorAssistantMessage['parts']): AuthorAssistantMessage { + seq += 1; + return { id: `m-${seq}`, role: 'assistant', parts, at: AT }; +} + +function okPart( + name: string, + toolCallId: string, + input: unknown = {}, + output: unknown = { ok: true } +): AuthorToolPart { + return { type: 'tool', toolCallId, name, input, output, state: 'done' }; +} + +function spec(name: string): AgentSpecV2 { + return { + version: 2, + name, + description: 'does a thing', + model: 'anthropic/claude-sonnet-5-5', + instructions: 'do a thing', + }; +} + +function card(proposalId: string, over: Partial = {}): ProposalOutput { + return { + ok: true, + proposalId, + kind: 'create', + spec: spec(`Agent ${proposalId}`), + changes: [], + requirements: [], + carriedOver: false, + carriedFields: [], + baseSpecHash: null, + enabled: false, + ...over, + }; +} + +/** A successful `proposeAgent` tool part: the card for a new agent. */ +function propose(toolCallId: string): AuthorToolPart { + return okPart('proposeAgent', toolCallId, { draft: { name: `Agent ${toolCallId}` } }, card(toolCallId)); +} + +/** A successful `proposeAgentUpdate` tool part: the card editing `agentId`. */ +function proposeUpdate(toolCallId: string, agentId: string): AuthorToolPart { + return okPart( + 'proposeAgentUpdate', + toolCallId, + { agentId, patch: {} }, + card(toolCallId, { kind: 'update', agentId, spec: spec(`Agent ${agentId}`) }) + ); +} + +function turn(transcript: AuthorMessage[]): AuthorTurn { + return { + sessionId: 'sess-1', + requestId: 'req-1', + focus: { mode: 'create' }, + storedTranscript: transcript, + userMessage: user('hi'), + signal: new AbortController().signal, + stepsRemaining: () => 12, + state: new Map(), + }; +} + +function codeOf(result: AuthorToolFailure | null): string { + expect(result).not.toBeNull(); + return result!.code; +} + +describe('saveBlockingError', () => { + it('1. empty transcript and empty turn state -> NO_PROPOSAL', () => { + expect(codeOf(saveBlockingError(turn([]), CREATE))).toBe('NO_PROPOSAL'); + }); + + it('2. card proposed this turn -> UNANSWERED', () => { + const t = turn([]); + proposalTurnState(t).proposed.set(identityKey(CREATE), card('p2')); + expect(codeOf(saveBlockingError(t, CREATE))).toBe('UNANSWERED'); + }); + + it('3. an open stored card -> null', () => { + expect(saveBlockingError(turn([assistant(propose('p1'))]), CREATE)).toBeNull(); + }); + + it('4. card saved by a saveAgent tool call -> ALREADY_SAVED', () => { + const t = [assistant(propose('p1'), okPart('saveAgent', 's1'))]; + expect(codeOf(saveBlockingError(turn(t), CREATE))).toBe('ALREADY_SAVED'); + }); + + it('5. card saved by the user\'s Apply button (uiEvent) -> ALREADY_SAVED', () => { + const t = [assistant(propose('p1')), saveEvent('p1')]; + expect(codeOf(saveBlockingError(turn(t), CREATE))).toBe('ALREADY_SAVED'); + }); + + it('6. card saved earlier this turn (S.saved) -> ALREADY_SAVED', () => { + const t = turn([assistant(propose('p1'))]); + proposalTurnState(t).saved.add(identityKey(CREATE)); + expect(codeOf(saveBlockingError(t, CREATE))).toBe('ALREADY_SAVED'); + }); + + it('7. card discarded in the stored transcript -> NO_PROPOSAL', () => { + const t = [assistant(propose('p1'), okPart('discardPendingChanges', 'd1', {}))]; + expect(codeOf(saveBlockingError(turn(t), CREATE))).toBe('NO_PROPOSAL'); + }); + + it('8. card discarded this turn (S.discarded) -> NO_PROPOSAL', () => { + const t = turn([assistant(propose('p1'))]); + proposalTurnState(t).discarded.add(identityKey(CREATE)); + expect(codeOf(saveBlockingError(t, CREATE))).toBe('NO_PROPOSAL'); + }); + + it('9. an open stored card plus a new card this turn -> UNANSWERED', () => { + const t = turn([assistant(propose('p1'))]); + proposalTurnState(t).proposed.set(identityKey(CREATE), card('p2')); + expect(codeOf(saveBlockingError(t, CREATE))).toBe('UNANSWERED'); + }); + + it('10. an update card for agent A never authorises a save for agent B -> NO_PROPOSAL', () => { + const t = [assistant(proposeUpdate('u1', 'agent-a'))]; + expect(codeOf(saveBlockingError(turn(t), update('agent-b')))).toBe('NO_PROPOSAL'); + }); + + it('11. a discard for agent B leaves agent A\'s card open -> null', () => { + const t = [ + assistant(proposeUpdate('u1', 'agent-a'), okPart('discardPendingChanges', 'd1', { agentId: 'agent-b' })), + ]; + expect(saveBlockingError(turn(t), update('agent-a'))).toBeNull(); + }); + + it('12. a failed propose part (ok:false output) is not a card -> NO_PROPOSAL', () => { + const failed: AuthorToolPart = { + type: 'tool', + toolCallId: 'p1', + name: 'proposeAgent', + input: { draft: {} }, + output: { ok: false, code: 'DRAFT_REVIEW', error: 'gaps', userMessage: 'fix it' }, + state: 'done', + }; + expect(codeOf(saveBlockingError(turn([assistant(failed)]), CREATE))).toBe('NO_PROPOSAL'); + }); +}); + +describe('resolveStoredProposal', () => { + it('13. an older card is STALE_PROPOSAL and names the newest id', () => { + const t = [assistant(propose('p1'), propose('p2'))]; + const r = resolveStoredProposal(t, CREATE, 'p1'); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.code).toBe('STALE_PROPOSAL'); + expect(r.latestProposalId).toBe('p2'); + } + }); + + it('14. the newest card resolves to its stored input and output', () => { + const p2 = propose('p2'); + const t = [assistant(propose('p1'), p2)]; + expect(resolveStoredProposal(t, CREATE, 'p2')).toEqual({ + ok: true, + proposalId: 'p2', + input: p2.input, + output: p2.output, + }); + }); + + it('15. an id no tool call used is UNKNOWN_PROPOSAL', () => { + const t = [assistant(propose('p1'))]; + expect(codeOf(resolveStoredProposal(t, CREATE, 'nope') as AuthorToolFailure)).toBe('UNKNOWN_PROPOSAL'); + }); + + it('16. an update card is UNKNOWN_PROPOSAL for the create save', () => { + const t = [assistant(proposeUpdate('u1', 'agent-a'))]; + expect(codeOf(resolveStoredProposal(t, CREATE, 'u1') as AuthorToolFailure)).toBe('UNKNOWN_PROPOSAL'); + }); + + it('17. a card for agent A resolved under agent B is WRONG_AGENT', () => { + const t = [assistant(proposeUpdate('u1', 'agent-a'))]; + expect(codeOf(resolveStoredProposal(t, update('agent-b'), 'u1') as AuthorToolFailure)).toBe('WRONG_AGENT'); + }); + + it('18. a toolCallId on more than one tool part is AMBIGUOUS_PROPOSAL', () => { + const t = [assistant(propose('p1'), okPart('getCurrentSpec', 'p1', { agentId: 'agent-a' }))]; + expect(codeOf(resolveStoredProposal(t, CREATE, 'p1') as AuthorToolFailure)).toBe('AMBIGUOUS_PROPOSAL'); + }); + + it('19. a saved card cannot be saved again -> ALREADY_SAVED', () => { + const t = [assistant(propose('p1'), okPart('saveAgent', 's1'))]; + expect(codeOf(resolveStoredProposal(t, CREATE, 'p1') as AuthorToolFailure)).toBe('ALREADY_SAVED'); + }); + + it('20. a discarded card is gone -> NO_PROPOSAL', () => { + const t = [assistant(propose('p1'), okPart('discardPendingChanges', 'd1', {}))]; + expect(codeOf(resolveStoredProposal(t, CREATE, 'p1') as AuthorToolFailure)).toBe('NO_PROPOSAL'); + }); +}); + +describe('transcriptProposalStatus', () => { + it('21. a uiEvent save for a different proposalId leaves the card open', () => { + const t = [assistant(propose('p1')), saveEvent('other-id')]; + expect(transcriptProposalStatus(t, CREATE)).toEqual({ state: 'open', latestProposalId: 'p1' }); + }); +}); + +describe('openProposalCards', () => { + it('22. update cards for two agents are both open, oldest first', () => { + const t = [assistant(proposeUpdate('u1', 'agent-a'), proposeUpdate('u2', 'agent-b'))]; + expect(openProposalCards(t)).toEqual([ + { proposalId: 'u1', kind: 'update', agentId: 'agent-a', name: 'Agent agent-a' }, + { proposalId: 'u2', kind: 'update', agentId: 'agent-b', name: 'Agent agent-b' }, + ]); + }); + + it('23. a saved create card drops out; a later update card stays', () => { + const t = [ + assistant(propose('p1'), okPart('saveAgent', 's1')), + assistant(proposeUpdate('u1', 'new-agent')), + ]; + expect(openProposalCards(t)).toEqual([ + { proposalId: 'u1', kind: 'update', agentId: 'new-agent', name: 'Agent new-agent' }, + ]); + }); +}); + +describe('identityOfProposal', () => { + const t = [assistant(propose('p1'), proposeUpdate('u1', 'agent-a'))]; + + it('24. maps a proposalId to its identity, or null', () => { + expect(identityOfProposal(t, 'p1')).toEqual({ kind: 'create' }); + expect(identityOfProposal(t, 'u1')).toEqual({ kind: 'update', agentId: 'agent-a' }); + expect(identityOfProposal(t, 'x')).toBeNull(); + }); +}); diff --git a/apps/agent-forge/server/author/proposal/failures.ts b/apps/agent-forge/server/author/proposal/failures.ts new file mode 100644 index 000000000..cfd529e5f --- /dev/null +++ b/apps/agent-forge/server/author/proposal/failures.ts @@ -0,0 +1,94 @@ +/** + * The save gate's refusal copy (PLAN §8.3 "Save refuses when ...", research + * §5.2): one builder per failure code the proposal gate can return. Like + * every Author tool failure, `error` and `next` are model-facing - they may + * name tools and ids so the model can recover - while `userMessage` is + * person-facing plain words and never contains a tool name or an id + * (`__tests__/failures.test.ts` scans for them). + */ +import type { AuthorToolFailure, ProposalKind } from '../../../shared/authorWire'; +import { toolFailure } from '../tools/failure'; + +/** + * The propose tool that shows a card for each proposal kind: `proposeAgent` + * for a new agent, `proposeAgentUpdate` for an edit. Declared here because + * the copy below names it; `gate.ts` re-exports it so the whole save-gate + * surface imports from one module. + */ +export const PROPOSE_TOOL = { create: 'proposeAgent', update: 'proposeAgentUpdate' } as const; + +/** + * `UNANSWERED`: the card was shown in this very turn, so the user cannot + * have answered it yet. `kind` keeps the signature uniform with the sibling + * builders (callers always pass `identity.kind`); this refusal's copy is + * the same for creates and updates. + */ +export function unansweredFailure(_kind: ProposalKind): AuthorToolFailure { + return toolFailure( + 'UNANSWERED', + 'The proposal card was shown in this turn, so the user has not answered it yet.', + 'Review the proposal and confirm it before it is saved.', + 'End your turn now and let the user read the card. Save only after they confirm in a later message. Never say it was saved.' + ); +} + +/** `NO_PROPOSAL`: no open card exists - it was discarded or never shown. */ +export function noProposalFailure(kind: ProposalKind): AuthorToolFailure { + return toolFailure( + 'NO_PROPOSAL', + 'There is no open proposal card to save.', + "There's nothing to save: this proposal was discarded or never shown. Ask the Author for a new proposal.", + `Call ${PROPOSE_TOOL[kind]}, show the card, and wait for the user's reply before saving.` + ); +} + +/** `ALREADY_SAVED`: the newest card was already saved, by the model or by the user's Apply button. */ +export function alreadySavedFailure(): AuthorToolFailure { + return toolFailure( + 'ALREADY_SAVED', + "The newest proposal card was already saved, by you or by the user's Apply button.", + 'This proposal has already been saved. Ask the Author for a new proposal if you want another change.', + 'Tell the user it is already saved. For a further change, propose it and wait for their reply.' + ); +} + +/** `UNKNOWN_PROPOSAL`: no successful propose part carries `proposalId` in this chat. */ +export function unknownProposalFailure(kind: ProposalKind, proposalId: string): AuthorToolFailure { + return toolFailure( + 'UNKNOWN_PROPOSAL', + `No ${PROPOSE_TOOL[kind]} card with proposalId "${proposalId}" exists in this chat.`, + "This proposal is no longer part of this chat, so it can't be saved. Ask the Author to propose it again.", + `Copy the proposalId verbatim from the successful ${PROPOSE_TOOL[kind]} result the user agreed to. Never invent one.` + ); +} + +/** `STALE_PROPOSAL`: `proposalId` is a real card but a newer one replaced it; only the newest can save. */ +export function staleProposalFailure(proposalId: string, latestProposalId: string): AuthorToolFailure { + return toolFailure( + 'STALE_PROPOSAL', + `Proposal "${proposalId}" is not the newest card; "${latestProposalId}" replaced it. Only the newest card can be saved.`, + 'A newer proposal replaced this one, and only the newest proposal can be saved. Review the newer proposal and apply that one instead.', + 'If the user agreed to the newest card, save it by its proposalId. Otherwise ask which version they want.', + { latestProposalId } + ); +} + +/** `WRONG_AGENT`: `proposalId` is an update card for `other`, not for `agentId`. */ +export function wrongAgentFailure(proposalId: string, other: string, agentId: string): AuthorToolFailure { + return toolFailure( + 'WRONG_AGENT', + `Proposal "${proposalId}" changes agent "${other}", not "${agentId}".`, + "This proposal changes a different agent, so it can't be saved here.", + "Save it with the agentId it was proposed for, or propose this agent's change first." + ); +} + +/** `AMBIGUOUS_PROPOSAL`: `proposalId` names more than one tool call, so the card cannot be identified. */ +export function ambiguousProposalFailure(kind: ProposalKind, proposalId: string): AuthorToolFailure { + return toolFailure( + 'AMBIGUOUS_PROPOSAL', + `proposalId "${proposalId}" names more than one tool call in this chat, so the card cannot be identified.`, + "This proposal can't be told apart from another step in this chat. Ask the Author to propose it again.", + `Call ${PROPOSE_TOOL[kind]} again with the configuration the user agreed to, let them confirm the new card, and save that one.` + ); +} diff --git a/apps/agent-forge/server/author/proposal/gate.ts b/apps/agent-forge/server/author/proposal/gate.ts new file mode 100644 index 000000000..373182bb4 --- /dev/null +++ b/apps/agent-forge/server/author/proposal/gate.ts @@ -0,0 +1,292 @@ +/** + * The save gate (PLAN §8.3 "Save refuses when ... (enforced in code)", + * research §5.2): pure functions that decide, from the STORED transcript + * (which ends at the previous turn) and this turn's `ProposalTurnState`, + * whether a save may run and which stored card it writes. Nothing here + * does I/O and nothing writes: a refusal is returned as an + * `AuthorToolFailure`, and an allowed save resolves to the stored card's + * input/output - AA10b's save tools and the Apply route perform the write + * behind these checks, so a proposal can never reach disk unconfirmed. + * + * A "card" is an ok tool part: `type: 'tool'`, `state: 'done'`, output a + * record with `ok === true`. A failed (`ok: false`) propose result - a + * bounced review included - is not a card. Update gates are per agent: a + * card for agent A never authorises a write to B. + */ +import type { + AuthorAssistantMessage, + AuthorMessage, + AuthorToolFailure, + AuthorToolPart, + ProposalOutput, +} from '../../../shared/authorWire'; +import type { TurnContextInput } from '../turnContext'; +import type { AuthorTurn } from '../turnRunner'; +import { identityKey, proposalTurnState, type ProposalIdentity } from './turnState'; +import { + PROPOSE_TOOL, + alreadySavedFailure, + ambiguousProposalFailure, + noProposalFailure, + staleProposalFailure, + unansweredFailure, + unknownProposalFailure, + wrongAgentFailure, +} from './failures'; + +/** + * The propose tool that shows a card for each proposal kind - declared in + * `failures.ts` (its refusal copy names it) and re-exported here so the + * whole save-gate contract imports from this one module. + */ +// fallow-ignore-next-line unused-export -- part of the AA10a save-gate contract; AA10b's save tools read it +export { PROPOSE_TOOL } from './failures'; + +/** The save tool that writes a card for each proposal kind (AA10b registers them). */ +// fallow-ignore-next-line unused-export -- part of the AA10a save-gate contract; AA10b's save tools read it +export const SAVE_TOOL = { create: 'saveAgent', update: 'saveAgentUpdate' } as const; + +/** Where an identity's newest card stands in the stored transcript. */ +export type ProposalState = 'none' | 'open' | 'saved'; + +/** The transcript scan's answer: the state plus the newest card's id (kept even after a discard). */ +export interface ProposalStatus { + state: ProposalState; + latestProposalId: string | null; +} + +/** `resolveStoredProposal`'s answer: the stored card's payload to write, or why it refused. */ +export type StoredProposalResult = + | { ok: true; proposalId: string; input: unknown; output: ProposalOutput } + | AuthorToolFailure; + +/** The tool names that carry an `agentId` and therefore belong to one update identity. */ +const PER_AGENT_TOOLS: ReadonlySet = new Set([ + PROPOSE_TOOL.update, + SAVE_TOOL.update, + 'discardPendingChanges', +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Whether the part is a card-carrying tool result: done, with an + * `{ ok: true }` object output. Error-state parts and `ok: false` + * outputs (a bounced review, a refusal) never count. + */ +function isOkToolPart( + part: AuthorAssistantMessage['parts'][number] +): part is AuthorToolPart & { output: Record } { + return part.type === 'tool' && part.state === 'done' && isRecord(part.output) && part.output.ok === true; +} + +/** The `agentId` a tool input carries, when it is a record with a string one. */ +function agentIdOf(input: unknown): string | undefined { + return isRecord(input) && typeof input.agentId === 'string' ? input.agentId : undefined; +} + +/** + * Whether a `discardPendingChanges` input clears `identity`: an input + * without `agentId` clears the create card (and the update scan filters + * per agent before this runs), `{ agentId }` clears that agent's card. + */ +function discardMatches(input: unknown, identity: ProposalIdentity): boolean { + const agentId = agentIdOf(input); + return identity.kind === 'create' ? agentId === undefined : agentId === identity.agentId; +} + +/** + * Whether an ok part belongs to `identity`'s stream at all: for an update + * identity, the per-agent tools (propose/save update, discard) only count + * when their `agentId` is this agent's - a card for A never moves B's gate. + */ +function belongsToIdentity(part: AuthorToolPart, identity: ProposalIdentity): boolean { + if (identity.kind !== 'update' || !PER_AGENT_TOOLS.has(part.name)) return true; + return agentIdOf(part.input) === identity.agentId; +} + +/** + * One ok part folded into the scan: the matching propose opens (and becomes + * the newest card), the matching save while open saves it, a matching + * discard clears it back to none. + */ +function applyPart(scan: ProposalStatus, part: AuthorToolPart, identity: ProposalIdentity): void { + if (part.name === PROPOSE_TOOL[identity.kind]) { + scan.state = 'open'; + scan.latestProposalId = part.toolCallId; + } else if (part.name === SAVE_TOOL[identity.kind] && scan.state === 'open') { + scan.state = 'saved'; + } else if (part.name === 'discardPendingChanges' && discardMatches(part.input, identity)) { + scan.state = 'none'; + } +} + +/** + * An identity's status across the stored transcript, in order. A `uiEvent` + * user message with `kind: 'save'` is the Apply button's footprint: it + * saves the card only when it names the newest open one. + */ +export function transcriptProposalStatus( + messages: readonly AuthorMessage[], + identity: ProposalIdentity +): ProposalStatus { + const scan: ProposalStatus = { state: 'none', latestProposalId: null }; + for (const message of messages) { + if (message.role === 'user') { + if ( + message.uiEvent?.kind === 'save' && + scan.state === 'open' && + message.uiEvent.proposalId === scan.latestProposalId + ) { + scan.state = 'saved'; + } + continue; + } + for (const part of message.parts) { + if (isOkToolPart(part) && belongsToIdentity(part, identity)) { + applyPart(scan, part, identity); + } + } + } + return scan; +} + +/** + * Why a model save must not run now, or `null` when it may. The this-turn + * checks come first: a card proposed this turn is `UNANSWERED` (the stored + * transcript cannot know about it), a discard this turn removes the card, + * and one confirmation buys exactly one save. Only then does the stored + * transcript - which ends at the previous turn - get scanned, so any open + * card found there was genuinely shown to the user. + */ +export function saveBlockingError(turn: AuthorTurn, identity: ProposalIdentity): AuthorToolFailure | null { + const key = identityKey(identity); + const state = proposalTurnState(turn); + if (state.proposed.has(key)) return unansweredFailure(identity.kind); + if (state.discarded.has(key)) return noProposalFailure(identity.kind); + if (state.saved.has(key)) return alreadySavedFailure(); + const status = transcriptProposalStatus(turn.storedTranscript, identity); + if (status.state === 'saved') return alreadySavedFailure(); + if (status.state === 'none') return noProposalFailure(identity.kind); + return null; +} + +/** + * The stored card `proposalId` refers to, for the save path that writes + * exactly what the user saw. Refuses when the id names more than one tool + * call (`AMBIGUOUS_PROPOSAL` - providers may reuse ids), no ok propose part + * (`UNKNOWN_PROPOSAL`), another agent's card (`WRONG_AGENT`), a superseded + * card (`STALE_PROPOSAL`, carrying `latestProposalId`), an already-saved + * one (`ALREADY_SAVED`), or a discarded/never-open one (`NO_PROPOSAL`). + */ +export function resolveStoredProposal( + messages: readonly AuthorMessage[], + identity: ProposalIdentity, + proposalId: string +): StoredProposalResult { + let matchingCalls = 0; + let found: (AuthorToolPart & { output: Record }) | undefined; + for (const part of toolParts(messages)) { + if (part.toolCallId !== proposalId) continue; + matchingCalls += 1; + if (isOkToolPart(part) && part.name === PROPOSE_TOOL[identity.kind]) found = part; + } + if (matchingCalls > 1) return ambiguousProposalFailure(identity.kind, proposalId); + if (found === undefined) return unknownProposalFailure(identity.kind, proposalId); + if (identity.kind === 'update' && agentIdOf(found.input) !== identity.agentId) { + return wrongAgentFailure(proposalId, agentIdOf(found.input) ?? 'unknown', identity.agentId); + } + const status = transcriptProposalStatus(messages, identity); + // `found` being an ok propose part for this identity guarantees a non-null latest. + if (status.latestProposalId !== proposalId) { + return staleProposalFailure(proposalId, status.latestProposalId ?? 'none'); + } + if (status.state === 'saved') return alreadySavedFailure(); + if (status.state !== 'open') return noProposalFailure(identity.kind); + return { ok: true, proposalId, input: found.input, output: found.output as unknown as ProposalOutput }; +} + +/** Every tool part of every assistant message, in transcript order. */ +function* toolParts(messages: readonly AuthorMessage[]): Generator { + for (const message of messages) { + if (message.role !== 'assistant') continue; + for (const part of message.parts) { + if (part.type === 'tool') yield part; + } + } +} + +/** + * The identity a `proposalId` belongs to - its ok propose part - or `null` + * when no card carries it. The Apply route (AA10b) needs this to pick which + * gate a bare `proposalId` is answered by; the update identity's `agentId` + * comes from the stored input (falling back to the card output). + */ +export function identityOfProposal( + messages: readonly AuthorMessage[], + proposalId: string +): ProposalIdentity | null { + for (const part of toolParts(messages)) { + if (part.toolCallId !== proposalId || !isOkToolPart(part)) continue; + if (part.name === PROPOSE_TOOL.create) return { kind: 'create' }; + if (part.name === PROPOSE_TOOL.update) { + const agentId = agentIdOf(part.input) ?? agentIdOf(part.output); + return agentId === undefined ? null : { kind: 'update', agentId }; + } + } + return null; +} + +/** The identity an ok propose part opened a card for, or `undefined` for any other tool. */ +function identityOfPart(part: AuthorToolPart): ProposalIdentity | undefined { + if (part.name === PROPOSE_TOOL.create) return { kind: 'create' }; + if (part.name === PROPOSE_TOOL.update) { + const agentId = agentIdOf(part.input) ?? agentIdOf(part.output); + return agentId === undefined ? undefined : { kind: 'update', agentId }; + } + return undefined; +} + +/** + * The still-open cards for the turn-context block (PLAN §8.1's "open + * proposal ids"), oldest card first so the list reads "newest last". Every + * identity whose transcript status is `open` contributes one entry named + * after its card's `spec.name`; saved or discarded cards drop out because + * their status is no longer open. + */ +export function openProposalCards(messages: readonly AuthorMessage[]): TurnContextInput['openProposals'] { + // The latest ok propose part per identity; delete+set re-inserts so the + // map stays ordered by each card's newest position in the transcript. + const latest = new Map(); + for (const part of toolParts(messages)) { + if (!isOkToolPart(part)) continue; + const identity = identityOfPart(part); + if (identity === undefined) continue; + const key = identityKey(identity); + latest.delete(key); + latest.set(key, { identity, part }); + } + const open: TurnContextInput['openProposals'] = []; + for (const entry of latest.values()) { + const status = transcriptProposalStatus(messages, entry.identity); + if (status.state !== 'open' || status.latestProposalId !== entry.part.toolCallId) continue; + open.push({ + proposalId: entry.part.toolCallId, + kind: entry.identity.kind, + ...(entry.identity.kind === 'update' && { agentId: entry.identity.agentId }), + name: cardName(entry), + }); + } + return open; +} + +/** A card's display name: its `spec.name`, else a usable fallback for a malformed stored part. */ +function cardName(entry: { identity: ProposalIdentity; part: AuthorToolPart }): string { + const output = entry.part.output; + if (isRecord(output) && isRecord(output.spec) && typeof output.spec.name === 'string') { + return output.spec.name; + } + return entry.identity.kind === 'update' ? entry.identity.agentId : entry.part.toolCallId; +} diff --git a/apps/agent-forge/server/author/wiring.ts b/apps/agent-forge/server/author/wiring.ts index 057503fc4..ca5db6b61 100644 --- a/apps/agent-forge/server/author/wiring.ts +++ b/apps/agent-forge/server/author/wiring.ts @@ -23,6 +23,7 @@ import { buildAuthorSystemPrompt, type AuthorPromptCatalogs } from './prompt/ind import { buildAuthorTools, type AuthorToolDeps } from './tools/index'; import { createAuthorCatalogService } from './tools/catalogService'; import { passReview } from './proposal/review'; +import { openProposalCards } from './proposal/gate'; import { computeRequirements, requirementDeps } from './requirements'; /** @@ -117,8 +118,14 @@ export function createAuthorRunner(deps: CreateAuthorRunnerDeps): AuthorTurnRunn requirements: async (spec) => computeRequirements(spec, requirementDeps((await workspaceForTurn(turn)).ref.root, deps.secrets)), }), - // Stand-in until AA07 lands the real turnContextInput. - turnContextInput: async (s, now) => ({ focus: s.focus, openProposals: [], now }), + // The transcript is the proposal store: AA10a's scan lists the still-open + // cards (named after each card's spec.name) for the context block. The + // focused agent's summary lands with the update-side tickets. + turnContextInput: async (s, now) => ({ + focus: s.focus, + openProposals: openProposalCards(s.transcript), + now, + }), ...(deps.providerFor ? { providerFor: deps.providerFor } : {}), }); }