From 2c562664337ce613c9c9f5e7f6999eae9a6697e9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 31 May 2026 03:58:25 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(loops):=20planner-declared=20edge=20li?= =?UTF-8?q?neage=20=E2=80=94=20TopologyMove.parentIndex=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #82. The kernel-inferred parentIndex is exact for every topology the current move vocabulary (refine/fanout/stop) can produce, but a future revisit/goto move (or any planner branching off a non-winner) needs the planner to DECLARE the branch source. The hook is now in place, additively: - TopologyMove refine/fanout gain optional `parentIndex` (the iteration this round branches from). - LoopPlanDescription gains `parentIndex`; createDynamicDriver.describePlan surfaces the pending move's value. - run-loop prefers a declared parentIndex over the inferred branchPoint: `planDesc?.parentIndex ?? (round 0 ? root : branchPoint(...))`. Nothing emits a non-trivial parentIndex yet (no revisit/goto move ships), so behavior is unchanged for refine/fanout — but the lineage is now faithful the moment such a move exists. Test: a declared parentIndex=0 overrides the inferred best-valid branch point. Full suite green, tsc + biome clean. --- src/loops/drivers/dynamic.ts | 21 ++++++++++------- src/loops/run-loop.ts | 10 ++++---- src/loops/types.ts | 7 ++++++ tests/loops/dynamic.test.ts | 44 ++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/loops/drivers/dynamic.ts b/src/loops/drivers/dynamic.ts index 87c29cd..dcbc1a6 100644 --- a/src/loops/drivers/dynamic.ts +++ b/src/loops/drivers/dynamic.ts @@ -40,8 +40,8 @@ export type DynamicDecision = 'continue' | 'done' * @experimental */ export type TopologyMove = - | { kind: 'refine'; task: Task; rationale?: string } - | { kind: 'fanout'; tasks: Task[]; rationale?: string } + | { kind: 'refine'; task: Task; rationale?: string; parentIndex?: number } + | { kind: 'fanout'; tasks: Task[]; rationale?: string; parentIndex?: number } | { kind: 'stop'; rationale?: string } /** @experimental */ @@ -135,13 +135,18 @@ export function createDynamicDriver( return pending?.kind === 'stop' ? 'done' : 'continue' }, describePlan() { - // Surface the move the planner just chose (kind + rationale) so the - // kernel's loop.plan trace event carries the agent's intent, not just the - // inferred fan-width. `pending` is the move set by the preceding plan(). + // Surface the move the planner just chose (kind + rationale + an optional + // DECLARED branch source) so the kernel's loop.plan trace carries the + // agent's intent — including faithful edge lineage when the planner + // branched off a specific (non-winner) iteration. `pending` is the move + // set by the preceding plan(). if (!pending) return undefined - return pending.rationale !== undefined - ? { kind: pending.kind, rationale: pending.rationale } - : { kind: pending.kind } + const out: { kind: string; rationale?: string; parentIndex?: number } = { kind: pending.kind } + if (pending.rationale !== undefined) out.rationale = pending.rationale + if (pending.kind !== 'stop' && pending.parentIndex !== undefined) { + out.parentIndex = pending.parentIndex + } + return out }, } } diff --git a/src/loops/run-loop.ts b/src/loops/run-loop.ts index b9843d9..671d66a 100644 --- a/src/loops/run-loop.ts +++ b/src/loops/run-loop.ts @@ -139,10 +139,12 @@ export async function runLoop( const baseIndex = iterations.length const remaining = maxIterations - iterations.length const slice = planned.slice(0, remaining) - // Edge lineage: round 0 branches from root (undefined); later rounds branch - // from the best-valid (else latest) iteration so far — emitted, not guessed, - // so a viewer draws the actual topology instead of inferring it. - const parentIndex = roundIndex === 0 ? undefined : branchPoint(iterations) + // Edge lineage: a driver may DECLARE the branch source (planner-authored + // topology); otherwise the kernel infers it — round 0 branches from root + // (undefined), later rounds from the best-valid (else latest) iteration so + // far. Either way it's emitted, not guessed by the viewer. + const parentIndex = + planDesc?.parentIndex ?? (roundIndex === 0 ? undefined : branchPoint(iterations)) const childIndices = slice.map((_, i) => baseIndex + i) await emitTrace(options.ctx.traceEmitter, { kind: 'loop.plan', diff --git a/src/loops/types.ts b/src/loops/types.ts index 76006a4..d1676d0 100644 --- a/src/loops/types.ts +++ b/src/loops/types.ts @@ -154,6 +154,13 @@ export interface LoopPlanDescription { kind: string /** Why the driver chose this move (the agent's rationale), when available. */ rationale?: string + /** + * Iteration index this round branches FROM, when the driver declares it. + * Overrides the kernel's inferred branch point — lets a planner that + * branches off a specific (non-winner) iteration emit faithful edge lineage. + * Omit to keep the inferred (best-valid / latest) branch point. + */ + parentIndex?: number } /** @experimental */ diff --git a/tests/loops/dynamic.test.ts b/tests/loops/dynamic.test.ts index 204b1b5..56c9693 100644 --- a/tests/loops/dynamic.test.ts +++ b/tests/loops/dynamic.test.ts @@ -545,3 +545,47 @@ describe('runLoop dynamic driver — trace emission for topology viewers', () => } }) }) + +describe('runLoop dynamic driver — planner-declared edge lineage (#82)', () => { + it('a declared move.parentIndex overrides the kernel-inferred branch point', async () => { + const goal = 'lineage' + // round 0: fanout → iter0 (naive=0.3 invalid) + iter1 (parallel-a=0.9 valid). + // round 1: refine DECLARING parentIndex 0 (branch off the WEAK iter, not the winner). + // Inferred branchPoint would pick the best-valid iter1; declared must win. + const moves: TopologyMove[] = [ + { + kind: 'fanout', + tasks: [ + { goal, strategy: 'naive' }, + { goal, strategy: 'parallel-a' }, + ], + }, + { kind: 'refine', task: { goal, strategy: 'parallel-x' }, parentIndex: 0 }, + { kind: 'stop' }, + ] + let round = 0 + const planner: TopologyPlanner = () => moves[round++]! + + const planPayloads: LoopPlanPayload[] = [] + const traceEmitter: LoopTraceEmitter = { + emit(e) { + if (e.kind === 'loop.plan') planPayloads.push(e.payload) + }, + } + const { client } = workerClient() + await runLoop({ + driver: createDynamicDriver({ planner }), + agentRuns: workerSpecs(['a', 'b']), + output, + validator, + task: { goal, strategy: 'naive' }, + ctx: { sandboxClient: client, traceEmitter }, + }) + + // round 0 fanout branches from root; round 1 refine declares parent 0 (the + // weak iteration), which must override the inferred best-valid (iter 1). + expect(planPayloads[0]?.parentIndex).toBeUndefined() + expect(planPayloads[1]?.moveKind).toBe('refine') + expect(planPayloads[1]?.parentIndex).toBe(0) + }) +}) From c4e378efb591152e018d2fae0b56f5a63f93672a Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 31 May 2026 03:58:30 -0600 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=200.40.0=20=E2=80=94=20plan?= =?UTF-8?q?ner-declared=20edge=20lineage=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e3d39c1..45cf9ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.39.0", + "version": "0.40.0", "description": "Reusable runtime lifecycle for domain-specific agents.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": {