diff --git a/apps/agent-forge/server/author/proposal/__tests__/instructionsEdits.test.ts b/apps/agent-forge/server/author/proposal/__tests__/instructionsEdits.test.ts new file mode 100644 index 00000000..44481c32 --- /dev/null +++ b/apps/agent-forge/server/author/proposal/__tests__/instructionsEdits.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { applyInstructionsEdits } from '../instructionsEdits'; + +/** + * applyInstructionsEdits (AA09): every `oldText` must occur exactly once, + * no two anchors may overlap, and the edits apply right-to-left so each + * splice sees the positions the others were anchored at. + */ + +const TEXT = 'alpha beta gamma delta'; + +describe('applyInstructionsEdits', () => { + it('applies two edits in one pass, right-to-left', () => { + const result = applyInstructionsEdits(TEXT, [ + { oldText: 'beta', newText: 'BETA' }, + { oldText: 'delta', newText: 'DELTA' }, + ]); + expect(result).toEqual({ ok: true, text: 'alpha BETA gamma DELTA' }); + }); + + it('anchors on the original text even when an earlier edit changes earlier positions', () => { + // The second edit's anchor sits after the first's: applying + // left-to-right with index recomputation would still land correctly, + // but a shared-replace implementation would break; the contract is a + // single result computed from the original positions. + const result = applyInstructionsEdits('do x then do y', [ + { oldText: 'do x', newText: 'DO X LONGER' }, + { oldText: 'do y', newText: 'DO Y' }, + ]); + expect(result).toEqual({ ok: true, text: 'DO X LONGER then DO Y' }); + }); + + it('deletes with an empty newText', () => { + const result = applyInstructionsEdits(TEXT, [{ oldText: ' beta', newText: '' }]); + expect(result).toEqual({ ok: true, text: 'alpha gamma delta' }); + }); + + it('an absent anchor is ANCHOR_NOT_FOUND at its edit index', () => { + const result = applyInstructionsEdits(TEXT, [ + { oldText: 'beta', newText: 'BETA' }, + { oldText: 'epsilon', newText: 'EPS' }, + ]); + expect(result).toEqual({ ok: false, code: 'ANCHOR_NOT_FOUND', index: 1 }); + }); + + it('an anchor occurring more than once is ANCHOR_AMBIGUOUS and reports the count', () => { + const result = applyInstructionsEdits('pick a queue, any queue', [{ oldText: 'queue', newText: 'line' }]); + expect(result).toEqual({ ok: false, code: 'ANCHOR_AMBIGUOUS', index: 0, occurrences: 2 }); + }); + + it('overlapping anchors are ANCHOR_OVERLAP naming both edits', () => { + const result = applyInstructionsEdits(TEXT, [ + { oldText: 'beta gamma', newText: 'x' }, + { oldText: 'gamma delta', newText: 'y' }, + ]); + expect(result).toEqual({ ok: false, code: 'ANCHOR_OVERLAP', index: 0, otherIndex: 1 }); + }); + + it('adjacent-but-not-overlapping edits succeed', () => { + const result = applyInstructionsEdits('ab', [ + { oldText: 'a', newText: 'A' }, + { oldText: 'b', newText: 'B' }, + ]); + expect(result).toEqual({ ok: true, text: 'AB' }); + }); +}); diff --git a/apps/agent-forge/server/author/proposal/fold.ts b/apps/agent-forge/server/author/proposal/fold.ts new file mode 100644 index 00000000..dceda2df --- /dev/null +++ b/apps/agent-forge/server/author/proposal/fold.ts @@ -0,0 +1,76 @@ +/** + * Pending-card folding (PLAN §8.3 "Edits fold earlier unapplied proposals + * for the same agent into the new one", research §5.4): `pendingUpdateCard` + * finds the newest still-open update card for an agent - this turn's own + * proposal record first, then the stored transcript's open card - and + * `changedTopLevelKeys` reports which spec fields that card moved, for the + * `carriedFields` a folded card reports. + * + * "Open" is the AA10a gate's definition: the newest ok propose part that no + * later save or discard closed. A card whose `baseSpecHash` no longer + * matches the saved spec (a canvas edit landed after it was shown) is + * returned here but ignored by the caller - folding is keyed on the spec + * the card was built against, not on the card alone. + */ +import type { AgentSpecV2 } from 'lousho'; +import type { ProposalOutput } from '../../../shared/authorWire'; +import { SPEC_FIELD_ORDER } from '../../../shared/specFields'; +import { stableStringify } from '../../../shared/stableJson'; +import type { AuthorTurn } from '../turnRunner'; +import { transcriptProposalStatus } from './gate'; +import { identityKey, proposalTurnState } from './turnState'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * The card `proposeAgentUpdate` folds: the one proposed earlier THIS turn + * (`proposalTurnState`, which the stored transcript cannot know yet), else + * the stored transcript's open card for `agentId`. `null` when the turn + * already discarded or saved the identity, or when no card stays open - + * an unsaved card is the only thing a new update may inherit. + */ +export function pendingUpdateCard(turn: AuthorTurn, agentId: string): ProposalOutput | null { + const identity = { kind: 'update', agentId } as const; + const key = identityKey(identity); + const state = proposalTurnState(turn); + if (state.discarded.has(key) || state.saved.has(key)) return null; + const proposed = state.proposed.get(key); + if (proposed !== undefined) return proposed; + const status = transcriptProposalStatus(turn.storedTranscript, identity); + if (status.state !== 'open') return null; + for (const message of turn.storedTranscript) { + if (message.role !== 'assistant') continue; + for (const part of message.parts) { + if ( + part.type === 'tool' && + part.state === 'done' && + part.toolCallId === status.latestProposalId && + isRecord(part.output) && + part.output.ok === true + ) { + return part.output as unknown as ProposalOutput; + } + } + } + return null; +} + +/** + * The spec fields whose canonical JSON differs between `a` and `b`, in + * `SPEC_FIELD_ORDER` - the "what did the earlier card change" list a fold + * turns into `carriedFields`. The compare is key-sorted (`stableStringify`) + * so a sub-object whose keys were merely reordered does not read as a + * change. `labels` is skipped: it is a meta field, not a spec key. + */ +export function changedTopLevelKeys(a: AgentSpecV2, b: AgentSpecV2): string[] { + const aRecord = a as unknown as Record; + const bRecord = b as unknown as Record; + const changed: string[] = []; + for (const field of SPEC_FIELD_ORDER) { + if (field === 'labels') continue; + if (stableStringify(aRecord[field]) !== stableStringify(bRecord[field])) changed.push(field); + } + return changed; +} diff --git a/apps/agent-forge/server/author/proposal/instructionsEdits.ts b/apps/agent-forge/server/author/proposal/instructionsEdits.ts new file mode 100644 index 00000000..6ef905fe --- /dev/null +++ b/apps/agent-forge/server/author/proposal/instructionsEdits.ts @@ -0,0 +1,86 @@ +/** + * `applyInstructionsEdits` - the anchored edits `proposeAgentUpdate`'s + * `instructionsEdits` input resolves against the current instructions + * text (research §4.1 `systemPromptEdits`). Each `oldText` is an anchor: + * it must occur exactly once so the model can never rewrite a passage it + * did not point at, and two anchors may not cover overlapping text. All + * anchors resolve against the ORIGINAL text, then apply right-to-left so + * an earlier splice cannot shift a later anchor's span. + * + * A failure names the edit index (and the other edit or the occurrence + * count) so the tool can build the `ANCHOR_*` failure the model retries + * from - the failure's `currentInstructions` is the caller's job, since + * only it knows which text the edits were tried against. + */ +export interface InstructionsEdit { + /** Text copied verbatim from the current instructions; must occur exactly once. */ + oldText: string; + /** The replacement; an empty string deletes `oldText`. */ + newText: string; +} + +/** The applied text, or why the anchor set is not usable. `index` is the 0-based edit at fault. */ +export type EditsResult = + | { ok: true; text: string } + | { + ok: false; + code: 'ANCHOR_NOT_FOUND' | 'ANCHOR_AMBIGUOUS' | 'ANCHOR_OVERLAP'; + index: number; + /** ANCHOR_OVERLAP only: the other edit `index` collides with. */ + otherIndex?: number; + /** ANCHOR_AMBIGUOUS only: how many times the anchor occurs. */ + occurrences?: number; + }; + +/** Every `[start, end)` span `anchor` covers in `text` (non-overlapping occurrences, in order). */ +function spansOf(text: string, anchor: string): Array { + const spans: Array = []; + let from = 0; + for (;;) { + const start = text.indexOf(anchor, from); + if (start === -1) return spans; + spans.push([start, start + anchor.length]); + from = start + anchor.length; + } +} + +/** The first overlapping edit pair, or `null` - `[i, j]` with `i < j`, edit order. */ +function firstOverlap(spans: readonly (readonly [number, number])[]): readonly [number, number] | null { + for (let i = 0; i < spans.length; i += 1) { + for (let j = i + 1; j < spans.length; j += 1) { + const a = spans[i]!; + const b = spans[j]!; + if (a[0] < b[1] && b[0] < a[1]) return [i, j]; + } + } + return null; +} + +/** + * Applies `edits` to `text`: every `oldText` must match exactly once and + * no two matches may overlap (adjacent is fine). Returns the new text, or + * the first `ANCHOR_*` problem in edit order - anchors are validated + * before any overlap check so a missing or ambiguous anchor always + * outranks a collision between well-formed ones. + */ +export function applyInstructionsEdits(text: string, edits: readonly InstructionsEdit[]): EditsResult { + const spans: Array = []; + for (let i = 0; i < edits.length; i += 1) { + const found = spansOf(text, edits[i]!.oldText); + if (found.length === 0) return { ok: false, code: 'ANCHOR_NOT_FOUND', index: i }; + if (found.length > 1) { + return { ok: false, code: 'ANCHOR_AMBIGUOUS', index: i, occurrences: found.length }; + } + spans.push(found[0]!); + } + const overlap = firstOverlap(spans); + if (overlap !== null) return { ok: false, code: 'ANCHOR_OVERLAP', index: overlap[0], otherIndex: overlap[1] }; + const ordered = edits + .map((edit, i) => ({ edit, span: spans[i]! })) + .sort((a, b) => b.span[0] - a.span[0]); + let result = text; + for (const { edit, span } of ordered) { + result = result.slice(0, span[0]) + edit.newText + result.slice(span[1]); + } + return { ok: true, text: result }; +} diff --git a/apps/agent-forge/server/author/tools/__tests__/proposeAgentUpdate.test.ts b/apps/agent-forge/server/author/tools/__tests__/proposeAgentUpdate.test.ts new file mode 100644 index 00000000..333598c9 --- /dev/null +++ b/apps/agent-forge/server/author/tools/__tests__/proposeAgentUpdate.test.ts @@ -0,0 +1,432 @@ +import { mkdtempSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { testToolContext } from 'lousho/testing'; +import type { AgentSpecV2 } from 'lousho'; +import type { AuthorToolDeps, CatalogService } from '../index'; +import type { AuthorTurn } from '../../turnRunner'; +import type { WorkspaceContext } from '../../../workspace/context'; +import { AgentRepo, defaultMeta } from '../../../storage/agentRepo'; +import { specHash } from '../../../storage/specHash'; +import { applyAuthorPatch } from '../../draft/toSpec'; +import { proposalTurnState } from '../../proposal/turnState'; +import { passReview } from '../../proposal/review'; +import type { + AuthorAssistantMessage, + AuthorMessage, + AuthorToolFailure, + AuthorToolPart, + ProposalOutput, +} from '../../../../shared/authorWire'; +import { AUTHOR_TOOL_FACTORIES, buildAuthorTools } from '../index'; + +/** + * proposeAgentUpdate (AA09): the patch card for an EXISTING agent - patch + * semantics over the saved spec, anchored instructionsEdits, and folding + * of an earlier unsaved card for the same agent (stored transcript or + * this turn's proposal record). Nothing is ever written to disk. + */ + +const SAVED: AgentSpecV2 = { + version: 2, + name: 'Support triage', + description: 'Routes support mail to the right queue.', + model: 'mock/mock-1', + instructions: 'You triage support mail. Pick a queue. Reply politely.', + tools: [{ builtin: 'web_fetch' }, { builtin: 'http_request' }], + triggers: [{ id: 't1', type: 'chat' }], +}; + +const SAVED_HASH = specHash(SAVED); + +/** A workspace rooted in a fresh tmpdir (the agent file is written per test by `writeAgent`). */ +function workspace(): { root: string; repo: AgentRepo } { + const root = mkdtempSync(path.join(tmpdir(), 'aa09-')); + return { root, repo: new AgentRepo(root) }; +} + +function turn(storedTranscript: AuthorMessage[] = []): AuthorTurn { + return { + sessionId: 'sess-1', + requestId: 'req-test', + focus: { mode: 'edit', agentId: 'a' }, + storedTranscript, + userMessage: { id: 'm-1', role: 'user', text: 'change it', at: '2026-01-01T00:00:00.000Z' }, + signal: new AbortController().signal, + stepsRemaining: () => 12, + state: new Map(), + }; +} + +function fakeCatalog(): CatalogService { + return { + listModels: () => [{ id: 'mock/mock-1' }] as never, + listTools: () => [{ name: 'web_fetch' }, { name: 'http_request' }] as never, + listGuardrails: () => [], + listTriggerTypes: () => [{ type: 'chat' }] as never, + listSkills: async () => [], + listKits: async () => [], + listRegistryMcpServers: async () => [], + }; +} + +function fakeDeps(repo: AgentRepo, over: Partial = {}): AuthorToolDeps { + return { + workspace: async () => ({ repo } as unknown as WorkspaceContext), + catalog: fakeCatalog(), + templates: { list: async () => [] }, + probeMcp: async () => ({ ok: true, tools: [] }), + docsDir: '/nonexistent-docs', + reviewDraft: passReview, + requirements: async () => [], + ...over, + }; +} + +function updateTool(t: AuthorTurn, deps: AuthorToolDeps) { + return buildAuthorTools(t, deps).find( + (candidate) => ((candidate as { name?: string }).name ?? candidate.displayName) === 'proposeAgentUpdate' + )!; +} + +async function call( + tool: ReturnType, + args: Record, + toolCallId = 'call_a' +): Promise { + return (await tool.execute!(args as never, testToolContext({ toolCallId }))) as + | ProposalOutput + | AuthorToolFailure; +} + +/** `applyAuthorPatch` as a spec-only helper for building pending-card fixtures. */ +function patchedSpec(patch: Parameters[1]): AgentSpecV2 { + const built = applyAuthorPatch(SAVED, patch); + if (!built.ok) throw new Error('fixture patch did not build'); + return built.spec; +} + +/** An ok `proposeAgentUpdate` tool part as a previous turn persisted it. */ +function proposePart(toolCallId: string, output: ProposalOutput): AuthorToolPart { + return { type: 'tool', name: 'proposeAgentUpdate', toolCallId, input: { agentId: 'a' }, output, state: 'done' }; +} + +function assistantMsg(parts: AuthorToolPart[]): AuthorAssistantMessage { + return { id: `m_${parts.map((p) => p.toolCallId).join('_')}`, role: 'assistant', parts, at: '2026-01-01T00:00:00.000Z' }; +} + +/** The ProposalOutput a stored open card carries, as turn 1's tool result recorded it. */ +function cardOutput(proposalId: string, spec: AgentSpecV2, baseSpecHash = SAVED_HASH): ProposalOutput { + return { + ok: true, + proposalId, + kind: 'update', + agentId: 'a', + spec, + changes: [], + requirements: [], + carriedOver: false, + carriedFields: [], + baseSpecHash, + enabled: true, + }; +} + +/** The open-card transcript a second turn sees after turn 1 proposed `spec`. */ +function transcriptWithCard(proposalId: string, spec: AgentSpecV2, extra: AuthorMessage[] = []): AuthorMessage[] { + return [assistantMsg([proposePart(proposalId, cardOutput(proposalId, spec))]), ...extra]; +} + +async function writeAgent(repo: AgentRepo, spec: AgentSpecV2 = SAVED, enabled = true): Promise { + await repo.writeSpec('a', spec); + await repo.writeMeta('a', { ...defaultMeta('2026-01-01T00:00:00.000Z'), enabled }); +} + +describe('proposeAgentUpdate', () => { + it('is registered in AUTHOR_TOOL_FACTORIES', () => { + const { repo } = workspace(); + const names = AUTHOR_TOOL_FACTORIES.map((factory) => factory(turn(), fakeDeps(repo))).map( + (tool) => (tool as { name?: string }).name ?? tool.displayName + ); + expect(names).toContain('proposeAgentUpdate'); + }); + + it('patch { tools: [] } on an agent with 2 tools removes each, carrying nothing, writing nothing', async () => { + const { root, repo } = workspace(); + await writeAgent(repo); + const before = readdirSync(root, { recursive: true }); + const specBefore = readFileSync(repo.specPath('a'), 'utf8'); + + const t = turn(); + const output = (await call(updateTool(t, fakeDeps(repo)), { agentId: 'a', patch: { tools: [] } })) as ProposalOutput; + + expect(output.ok).toBe(true); + expect(output.kind).toBe('update'); + expect(output.agentId).toBe('a'); + expect(output.proposalId).toBe('call_a'); + expect(output.baseSpecHash).toBe(SAVED_HASH); + expect(output.enabled).toBe(true); + expect(output.carriedOver).toBe(false); + expect(output.carriedFields).toEqual([]); + expect(output.spec.tools ?? []).toEqual([]); + const removes = output.changes.filter((change) => change.op === 'remove'); + expect(removes.map((change) => change.field)).toEqual(['tools[web_fetch]', 'tools[http_request]']); + expect(proposalTurnState(t).proposed.get('update:a')).toBe(output); + expect(readdirSync(root, { recursive: true })).toEqual(before); + expect(readFileSync(repo.specPath('a'), 'utf8')).toBe(specBefore); + }); + + it('reports meta.enabled on the card', async () => { + const { repo } = workspace(); + await writeAgent(repo, SAVED, false); + const output = (await call(updateTool(turn(), fakeDeps(repo)), { + agentId: 'a', + patch: { description: 'Shorter pitch.' }, + })) as ProposalOutput; + expect(output.enabled).toBe(false); + }); + + it('an unknown agentId is AGENT_NOT_FOUND', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const output = (await call(updateTool(turn(), fakeDeps(repo)), { + agentId: 'ghost', + patch: { description: 'x' }, + })) as AuthorToolFailure; + expect(output.ok).toBe(false); + expect(output.code).toBe('AGENT_NOT_FOUND'); + }); + + it('an empty patch and no instructionsEdits is EMPTY_PATCH', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const output = (await call(updateTool(turn(), fakeDeps(repo)), { agentId: 'a', patch: {} })) as AuthorToolFailure; + expect(output.ok).toBe(false); + expect(output.code).toBe('EMPTY_PATCH'); + expect(output.error).toBe('The patch changes nothing.'); + }); + + it('patch.instructions plus instructionsEdits is EDITS_AND_INSTRUCTIONS', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const output = (await call(updateTool(turn(), fakeDeps(repo)), { + agentId: 'a', + patch: { instructions: 'Full rewrite.' }, + instructionsEdits: [{ oldText: 'Pick a queue.', newText: 'Pick the queue.' }], + })) as AuthorToolFailure; + expect(output.ok).toBe(false); + expect(output.code).toBe('EDITS_AND_INSTRUCTIONS'); + }); + + it('a { file } instructions field is INSTRUCTIONS_IN_FILE naming the file', async () => { + const { repo } = workspace(); + await writeAgent(repo, { ...SAVED, instructions: { file: 'prompts/triage.md' } }); + const output = (await call(updateTool(turn(), fakeDeps(repo)), { + agentId: 'a', + patch: {}, + instructionsEdits: [{ oldText: 'Pick a queue.', newText: 'Pick the queue.' }], + })) as AuthorToolFailure; + expect(output.ok).toBe(false); + expect(output.code).toBe('INSTRUCTIONS_IN_FILE'); + expect(output.error).toContain('"prompts/triage.md"'); + }); + + it('a valid pair of anchored edits applies both to the saved instructions', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const output = (await call(updateTool(turn(), fakeDeps(repo)), { + agentId: 'a', + patch: {}, + instructionsEdits: [ + { oldText: 'Pick a queue.', newText: 'Pick the queue.' }, + { oldText: 'Reply politely.', newText: '' }, + ], + })) as ProposalOutput; + expect(output.ok).toBe(true); + expect(output.spec.instructions).toBe('You triage support mail. Pick the queue. '); + }); + + it('an anchor that matches nothing fails with the current instructions and retryWith', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const output = (await call(updateTool(turn(), fakeDeps(repo)), { + agentId: 'a', + patch: {}, + instructionsEdits: [{ oldText: 'No such sentence.', newText: 'x' }], + })) as AuthorToolFailure; + expect(output.ok).toBe(false); + expect(output.code).toBe('ANCHOR_NOT_FOUND'); + expect(output.currentInstructions).toBe(SAVED.instructions); + expect(output.retryWith).toBe('instructions'); + }); + + it('folds an unsaved card from the stored transcript: both changes land, earlier field is carried', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const pendingSpec = patchedSpec({ description: 'Turn 1 rewrote the pitch.' }); + const t = turn(transcriptWithCard('p1', pendingSpec)); + + const output = (await call(updateTool(t, fakeDeps(repo)), { + agentId: 'a', + patch: { tools: [] }, + })) as ProposalOutput; + + expect(output.ok).toBe(true); + expect(output.spec.description).toBe('Turn 1 rewrote the pitch.'); + expect(output.spec.tools ?? []).toEqual([]); + expect(output.carriedOver).toBe(true); + expect(output.carriedFields).toEqual(['description']); + const fields = output.changes.map((change) => change.field); + expect(fields).toContain('description'); + expect(fields).toEqual(expect.arrayContaining(['tools[web_fetch]', 'tools[http_request]'])); + }); + + it('folds an unsaved card proposed earlier in the SAME turn', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const t = turn(); + const tool = updateTool(t, fakeDeps(repo)); + await call(tool, { agentId: 'a', patch: { description: 'Same-turn rewrite.' } }, 'call_1'); + const output = (await call(tool, { agentId: 'a', patch: { tools: [] } }, 'call_2')) as ProposalOutput; + + expect(output.ok).toBe(true); + expect(output.spec.description).toBe('Same-turn rewrite.'); + expect(output.spec.tools ?? []).toEqual([]); + expect(output.carriedFields).toEqual(['description']); + }); + + it('does not fold a stored card that was discarded', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const pendingSpec = patchedSpec({ description: 'Discarded rewrite.' }); + const discard: AuthorToolPart = { + type: 'tool', + name: 'discardPendingChanges', + toolCallId: 'd1', + input: { agentId: 'a' }, + output: { ok: true }, + state: 'done', + }; + const t = turn([assistantMsg([proposePart('p1', cardOutput('p1', pendingSpec)), discard])]); + const output = (await call(updateTool(t, fakeDeps(repo)), { + agentId: 'a', + patch: { tools: [] }, + })) as ProposalOutput; + expect(output.ok).toBe(true); + expect(output.spec.description).toBe(SAVED.description); + expect(output.carriedOver).toBe(false); + expect(output.carriedFields).toEqual([]); + }); + + it('does not fold a stored card that was already saved (Apply uiEvent)', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const pendingSpec = patchedSpec({ description: 'Already saved rewrite.' }); + const saveEvent: AuthorMessage = { + id: 'm_save', + role: 'user', + text: '', + at: '2026-01-01T00:00:00.000Z', + uiEvent: { kind: 'save', proposalId: 'p1', agentId: 'a', version: 2 }, + }; + const t = turn(transcriptWithCard('p1', pendingSpec, [saveEvent])); + const output = (await call(updateTool(t, fakeDeps(repo)), { + agentId: 'a', + patch: { tools: [] }, + })) as ProposalOutput; + expect(output.ok).toBe(true); + expect(output.spec.description).toBe(SAVED.description); + expect(output.carriedOver).toBe(false); + }); + + it('does not fold when the turn discarded or saved the card this turn', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const pendingSpec = patchedSpec({ description: 'Turn-state rewrite.' }); + for (const mark of ['discarded', 'saved'] as const) { + const t = turn(transcriptWithCard('p1', pendingSpec)); + proposalTurnState(t)[mark].add('update:a'); + const output = (await call(updateTool(t, fakeDeps(repo)), { + agentId: 'a', + patch: { tools: [] }, + })) as ProposalOutput; + expect(output.ok).toBe(true); + expect(output.spec.description).toBe(SAVED.description); + expect(output.carriedOver).toBe(false); + } + }); + + it('ignores a pending card whose base no longer matches the saved spec (canvas edit)', async () => { + const { repo } = workspace(); + await writeAgent(repo); // disk now holds SAVED + const staleSpec = patchedSpec({ description: 'Card based on an older spec.' }); + const card = cardOutput('p1', staleSpec, specHash({ ...SAVED, description: 'The spec the card was built on.' })); + const t = turn([assistantMsg([proposePart('p1', card)])]); + const output = (await call(updateTool(t, fakeDeps(repo)), { + agentId: 'a', + patch: { tools: [] }, + })) as ProposalOutput; + expect(output.ok).toBe(true); + expect(output.spec.description).toBe(SAVED.description); + expect(output.carriedOver).toBe(false); + expect(output.carriedFields).toEqual([]); + }); + + it('retries a missed anchor against the saved instructions and drops instructions from carriedFields', async () => { + const { repo } = workspace(); + await writeAgent(repo); + // The pending card rewrote instructions entirely (anchors no longer match) + // AND changed the description; the edits only fit the saved text. + const pendingSpec = patchedSpec({ + description: 'Pending pitch.', + instructions: 'A wholly rewritten prompt with no shared anchors.', + }); + const t = turn(transcriptWithCard('p1', pendingSpec)); + const output = (await call(updateTool(t, fakeDeps(repo)), { + agentId: 'a', + patch: {}, + instructionsEdits: [{ oldText: 'Pick a queue.', newText: 'Pick the queue.' }], + })) as ProposalOutput; + + expect(output.ok).toBe(true); + expect(output.spec.instructions).toBe('You triage support mail. Pick the queue. Reply politely.'); + expect(output.spec.description).toBe('Pending pitch.'); + expect(output.carriedOver).toBe(true); + expect(output.carriedFields).toEqual(['description']); + }); + + it('an anchor that fails against both bases reports the folded base instructions', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const pendingSpec = patchedSpec({ instructions: 'Pending prompt text.' }); + const t = turn(transcriptWithCard('p1', pendingSpec)); + const output = (await call(updateTool(t, fakeDeps(repo)), { + agentId: 'a', + patch: {}, + instructionsEdits: [{ oldText: 'Matches neither text.', newText: 'x' }], + })) as AuthorToolFailure; + expect(output.ok).toBe(false); + expect(output.code).toBe('ANCHOR_NOT_FOUND'); + expect(output.currentInstructions).toBe('Pending prompt text.'); + expect(output.retryWith).toBe('instructions'); + }); + + it('a failing review returns its failure and leaves proposed empty', async () => { + const { repo } = workspace(); + await writeAgent(repo); + const reviewFailure: AuthorToolFailure = { + ok: false, + code: 'DRAFT_REVIEW', + error: 'The change would not run.', + userMessage: 'The Author is still working on the change.', + }; + const t = turn(); + const output = (await call( + updateTool(t, fakeDeps(repo, { reviewDraft: async () => ({ pass: false, failure: reviewFailure }) })), + { agentId: 'a', patch: { tools: [] } } + )) as AuthorToolFailure; + expect(output).toBe(reviewFailure); + expect(proposalTurnState(t).proposed.size).toBe(0); + }); +}); diff --git a/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts b/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts index a90ff443..7af90c7e 100644 --- a/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts +++ b/apps/agent-forge/server/author/tools/__tests__/readTools.test.ts @@ -109,15 +109,15 @@ function failure(value: unknown): AuthorToolFailure { } describe('buildAuthorTools', () => { - it('returns the 14 tools sorted by name, every one in AUTHOR_TOOL_NAMES', () => { + it('returns the 15 tools sorted by name, every one in AUTHOR_TOOL_NAMES', () => { const built = buildAuthorTools(TURN, fakeDeps()); const names = built.map(nameOf); - expect(names).toHaveLength(14); + expect(names).toHaveLength(15); expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); for (const name of names) { expect(AUTHOR_TOOL_NAMES as readonly string[]).toContain(name); } - expect(AUTHOR_TOOL_FACTORIES).toHaveLength(14); + expect(AUTHOR_TOOL_FACTORIES).toHaveLength(15); }); it('builds the same list for different turns (the tool surface never changes mid-session)', () => { diff --git a/apps/agent-forge/server/author/tools/index.ts b/apps/agent-forge/server/author/tools/index.ts index c012b3b2..7c9c970d 100644 --- a/apps/agent-forge/server/author/tools/index.ts +++ b/apps/agent-forge/server/author/tools/index.ts @@ -35,6 +35,7 @@ import { probeMcpServerTool } from './mcpProbeTool'; import { searchDocsTool } from './searchDocsTool'; import { openPageTool } from './openPageTool'; import { proposeAgentTool } from './proposeAgentTool'; +import { proposeAgentUpdateTool } from './proposeAgentUpdateTool'; /** * A registry-index item that names an MCP server (`type: 'mcp'`, @@ -116,6 +117,7 @@ export const AUTHOR_TOOL_FACTORIES: readonly AuthorToolFactory[] = [ searchDocsTool, openPageTool, proposeAgentTool, + proposeAgentUpdateTool, ]; /** The call name a `ToolDescriptor` carries (`defineTool`/loader results), else its display name. */ diff --git a/apps/agent-forge/server/author/tools/proposeAgentUpdateTool.ts b/apps/agent-forge/server/author/tools/proposeAgentUpdateTool.ts new file mode 100644 index 00000000..d717b9f1 --- /dev/null +++ b/apps/agent-forge/server/author/tools/proposeAgentUpdateTool.ts @@ -0,0 +1,309 @@ +/** + * The Author's `proposeAgentUpdate` tool (PLAN §8.2, AA09): shows the user + * a proposal card that changes an EXISTING agent and writes nothing. The + * card's `spec` is the saved spec plus any earlier unsaved card for the + * same agent plus this call's patch - the fold (research §5.4) - and the + * card's `changes` are a server-computed `diffSpecs` against the saved + * spec, so the card always shows the whole change, not just this call's. + * + * The pipeline (research §4.1 `executeUpdatePreview`): the agent must + * exist, the patch must change something, `patch.instructions` and + * `instructionsEdits` are mutually exclusive, an earlier open card folds + * in only while its `baseSpecHash` still matches the saved spec (a canvas + * edit breaks the chain), anchored edits resolve against the folded base + * and fall back to the saved instructions, then the merged patch runs the + * same build / catalog / requirements / review gauntlet as `proposeAgent`. + */ +import { defineTool } from 'lousho'; +import { z } from 'zod'; +import type { AgentSpecV2 } from 'lousho'; +import type { AuthorToolFailure, ProposalOutput } from '../../../shared/authorWire'; +import { diffSpecs } from '../../diff/specChange'; +import { specHash } from '../../storage/specHash'; +import { authorPatchSchema, type AuthorPatch } from '../draft/schema'; +import { applyAuthorPatch, draftInvalidFailure } from '../draft/toSpec'; +import { checkCatalogs, loadCatalogFacts } from '../proposal/catalogChecks'; +import { changedTopLevelKeys, pendingUpdateCard } from '../proposal/fold'; +import { applyInstructionsEdits, type EditsResult, type InstructionsEdit } from '../proposal/instructionsEdits'; +import { identityKey, proposalTurnState } from '../proposal/turnState'; +import type { AuthorTurn } from '../turnRunner'; +import { guardedToolCall, toolFailure } from './failure'; +import type { AuthorToolDeps, AuthorToolFactory } from './index'; + +/** The person-facing sentence every anchor failure shares (the model-facing copy differs per code). */ +const ANCHOR_USER_MESSAGE = "The Author's edit didn't match the current instructions. It is retrying."; + +/** `AGENT_NOT_FOUND`, same copy as getCurrentSpec (AA06). */ +function agentNotFoundFailure(agentId: string): AuthorToolFailure { + return toolFailure( + 'AGENT_NOT_FOUND', + `No agent with id "${agentId}".`, + "That agent doesn't exist.", + 'Call listAgents and copy an id from it.' + ); +} + +/** `EMPTY_PATCH`: neither the patch nor `instructionsEdits` carries a change. */ +function emptyPatchFailure(): AuthorToolFailure { + return toolFailure( + 'EMPTY_PATCH', + 'The patch changes nothing.', + 'The Author sent an empty change. It is retrying.', + 'Include at least one field to change, or instructionsEdits.' + ); +} + +/** `EDITS_AND_INSTRUCTIONS`: a full instructions rewrite and anchored edits in one call. */ +function editsAndInstructionsFailure(): AuthorToolFailure { + return toolFailure( + 'EDITS_AND_INSTRUCTIONS', + 'Send either patch.instructions or instructionsEdits, not both.', + 'The Author sent conflicting instruction changes. It is retrying.', + 'Use instructionsEdits for a partial change and patch.instructions only for a full rewrite.' + ); +} + +/** `INSTRUCTIONS_IN_FILE`: the spec's instructions are a `{ file }` reference anchored edits cannot rewrite. */ +function instructionsInFileFailure(instructions: AgentSpecV2['instructions']): AuthorToolFailure { + const file = + typeof instructions === 'object' && instructions !== null && 'file' in instructions + ? String(instructions.file) + : 'unknown'; + return toolFailure( + 'INSTRUCTIONS_IN_FILE', + `This agent's instructions live in the file "${file}", so they cannot be edited here.`, + "This agent's instructions are in a file, so the Author can't edit them here.", + 'Tell the user to edit that file. Send a full instructions string only if they ask to replace the file reference.' + ); +} + +/** + * The `ANCHOR_*` failure for a rejected edit set. `currentInstructions` is + * exactly the text the edits were tried against (the folded base's), so the + * model can copy a verbatim anchor from it; `retryWith: 'instructions'` + * flags that a full rewrite is also a way out. + */ +function anchorFailure(result: Extract, currentInstructions: string): AuthorToolFailure { + const extra = { currentInstructions, retryWith: 'instructions' }; + switch (result.code) { + case 'ANCHOR_NOT_FOUND': + return toolFailure( + 'ANCHOR_NOT_FOUND', + `instructionsEdits[${result.index}].oldText was not found in the current instructions.`, + ANCHOR_USER_MESSAGE, + 'Copy oldText verbatim from currentInstructions, or send the full instructions instead.', + extra + ); + case 'ANCHOR_AMBIGUOUS': + return toolFailure( + 'ANCHOR_AMBIGUOUS', + `instructionsEdits[${result.index}].oldText occurs ${result.occurrences ?? 0} times; it must occur exactly once.`, + ANCHOR_USER_MESSAGE, + 'Include more surrounding text so it matches exactly once.', + extra + ); + default: + return toolFailure( + 'ANCHOR_OVERLAP', + `instructionsEdits[${result.index}] and instructionsEdits[${result.otherIndex ?? '?'}] overlap.`, + ANCHOR_USER_MESSAGE, + 'Merge them into one edit.', + extra + ); + } +} + +/** The base the new patch applies to and the field names an earlier open card already changed. */ +interface BaseResolution { + /** `pending.spec` while the earlier card still patches today's saved spec, else `saved`. */ + base: AgentSpecV2; + /** Fields the folded card contributed (empty without a usable pending card). */ + carried: string[]; + /** Whether `base` is the pending card's spec. */ + usable: boolean; +} + +/** + * The fold step (PLAN §8.3): the newest still-open update card for this + * agent becomes the patch's base only while the saved spec is still the + * spec that card was built on (`baseSpecHash`). A canvas edit after the + * card was shown breaks the chain - the pending card is ignored, not + * merged, so it can never resurrect a spec the user overwrote. + */ +function resolveBase(turn: AuthorTurn, agentId: string, saved: AgentSpecV2, savedHash: string): BaseResolution { + const pending = pendingUpdateCard(turn, agentId); + const usable = pending !== null && pending.baseSpecHash === savedHash; + return { + base: usable && pending !== null ? pending.spec : saved, + carried: usable && pending !== null ? changedTopLevelKeys(saved, pending.spec) : [], + usable, + }; +} + +/** What {@link applyEdits} yields: the patch to build (possibly with resolved `instructions`), or the failure. */ +type EditsResolution = { ok: true; effectivePatch: AuthorPatch; carried: string[] } | AuthorToolFailure; + +/** + * Resolves `instructionsEdits` against the base instructions: anchors must + * land on the folded text, and when they do not the SAVED instructions get + * one retry (the model may have copied its anchor from getCurrentSpec + * instead of from the folded card) - a successful retry drops + * `instructions` from `carried`, since the result no longer inherits the + * pending card's prompt. The resolution becomes `patch.instructions`, so + * the rest of the pipeline sees one merged patch. + */ +function applyEdits( + patch: AuthorPatch, + edits: readonly InstructionsEdit[] | undefined, + resolved: BaseResolution, + saved: AgentSpecV2 +): EditsResolution { + if (edits === undefined || edits.length === 0) { + return { ok: true, effectivePatch: patch, carried: resolved.carried }; + } + if (typeof resolved.base.instructions !== 'string') { + return instructionsInFileFailure(resolved.base.instructions); + } + let result = applyInstructionsEdits(resolved.base.instructions, edits); + let carried = resolved.carried; + if (!result.ok && resolved.usable && typeof saved.instructions === 'string') { + const retry = applyInstructionsEdits(saved.instructions, edits); + if (retry.ok) { + result = retry; + carried = carried.filter((key) => key !== 'instructions'); + } + } + if (!result.ok) return anchorFailure(result, resolved.base.instructions); + return { ok: true, effectivePatch: { ...patch, instructions: result.text }, carried }; +} + +/** The fields `proposeAgentUpdate` still needs once the agent is found and the edits resolved. */ +interface CardContext { + turn: AuthorTurn; + deps: AuthorToolDeps; + agentId: string; + toolCallId: string; + saved: AgentSpecV2; + savedHash: string; + enabled: boolean; + base: AgentSpecV2; + effectivePatch: AuthorPatch; + carried: string[]; +} + +/** + * Builds and records the card: merged patch -> spec validation + * (`draftInvalidFailure` on schema problems) -> catalog checks -> the diff + * against the saved spec -> `carriedFields` (what the fold contributed + * that this call did not itself touch) -> advisory requirements -> the + * pluggable review. Only a passing run lands in `proposalTurnState` and + * reaches the model as the card. + */ +async function buildCard(ctx: CardContext): Promise { + const built = applyAuthorPatch(ctx.base, ctx.effectivePatch); + if (!built.ok) return draftInvalidFailure('proposeAgentUpdate', built.issues); + const catalogFailure = checkCatalogs(built.spec, await loadCatalogFacts(ctx.turn, ctx.deps), 'proposeAgentUpdate'); + if (catalogFailure !== null) return catalogFailure; + const changes = diffSpecs({ spec: ctx.saved, labels: [] }, { spec: built.spec, labels: [] }); + const patchKeys = new Set( + Object.keys(ctx.effectivePatch).filter( + (key) => (ctx.effectivePatch as Record)[key] !== undefined + ) + ); + const carriedFields = ctx.carried.filter((key) => !patchKeys.has(key)); + // Requirements are advisory: a dep failure means an empty list, never a failed call. + const requirements = await ctx.deps.requirements(built.spec).catch(() => []); + const verdict = await ctx.deps.reviewDraft({ + turn: ctx.turn, + identity: { kind: 'update', agentId: ctx.agentId }, + toolName: 'proposeAgentUpdate', + spec: built.spec, + changes, + requirements, + }); + if (!verdict.pass) return verdict.failure; + const output: ProposalOutput = { + ok: true, + proposalId: ctx.toolCallId, + kind: 'update', + agentId: ctx.agentId, + spec: built.spec, + changes, + requirements, + carriedOver: carriedFields.length > 0, + carriedFields, + baseSpecHash: ctx.savedHash, + enabled: ctx.enabled, + }; + proposalTurnState(ctx.turn).proposed.set(identityKey({ kind: 'update', agentId: ctx.agentId }), output); + return output; +} + +/** The earliest refusal that applies, in pipeline order, or `null`. */ +function inputFailure( + patch: AuthorPatch, + edits: readonly InstructionsEdit[] | undefined +): AuthorToolFailure | null { + if (Object.keys(patch).length === 0 && (edits === undefined || edits.length === 0)) { + return emptyPatchFailure(); + } + if (patch.instructions !== undefined && edits !== undefined && edits.length > 0) { + return editsAndInstructionsFailure(); + } + return null; +} + +/** AA09's update tool for existing agents; creates are `proposeAgent` (AA08). */ +export const proposeAgentUpdateTool: AuthorToolFactory = (turn, deps) => + defineTool({ + name: 'proposeAgentUpdate', + description: + 'Show the user a proposal card that changes an EXISTING agent. Nothing is saved. An earlier unsaved card for the same agent is folded in. After calling it, end your turn and wait for the user to confirm.', + input: z.object({ + agentId: z.string().min(1).max(128).describe('Id of the agent to change, from listAgents or the turn context.'), + patch: authorPatchSchema.describe( + 'Only the fields that change. Omitted = keep, a list replaces the whole list, [] clears, null resets to the default.' + ), + instructionsEdits: z + .array( + z + .object({ + oldText: z + .string() + .min(1) + .max(5000) + .describe('Text copied verbatim from the current instructions. Must occur exactly once.'), + newText: z.string().max(20000).describe('The replacement. An empty string deletes oldText.'), + }) + .describe('One anchored edit: replace the one occurrence of oldText with newText.') + ) + .max(20) + .optional() + .describe('Anchored edits to the instructions. Never combine with patch.instructions.'), + }), + execute: ({ agentId, patch, instructionsEdits }, ctx) => + guardedToolCall('proposeAgentUpdate', async () => { + const { repo } = await deps.workspace(); + const saved = await repo.readSpec(agentId); + if (saved === undefined) return agentNotFoundFailure(agentId); + const refused = inputFailure(patch, instructionsEdits); + if (refused !== null) return refused; + const meta = await repo.readMeta(agentId); + const savedHash = specHash(saved); + const resolved = resolveBase(turn, agentId, saved, savedHash); + const edits = applyEdits(patch, instructionsEdits, resolved, saved); + if (!edits.ok) return edits; + return buildCard({ + turn, + deps, + agentId, + toolCallId: ctx.toolCallId, + saved, + savedHash, + enabled: meta.enabled, + base: resolved.base, + effectivePatch: edits.effectivePatch, + carried: edits.carried, + }); + }), + });