Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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' });
});
});
76 changes: 76 additions & 0 deletions apps/agent-forge/server/author/proposal/fold.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>;
const bRecord = b as unknown as Record<string, unknown>;
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;
}
86 changes: 86 additions & 0 deletions apps/agent-forge/server/author/proposal/instructionsEdits.ts
Original file line number Diff line number Diff line change
@@ -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<readonly [number, number]> {
const spans: Array<readonly [number, number]> = [];
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<readonly [number, number]> = [];
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 };
}
Loading
Loading