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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
21 changes: 13 additions & 8 deletions src/loops/drivers/dynamic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export type DynamicDecision = 'continue' | 'done'
* @experimental
*/
export type TopologyMove<Task> =
| { 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 */
Expand Down Expand Up @@ -135,13 +135,18 @@ export function createDynamicDriver<Task, Output>(
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
},
}
}
Expand Down
10 changes: 6 additions & 4 deletions src/loops/run-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,12 @@ export async function runLoop<Task, Output, Decision>(
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',
Expand Down
7 changes: 7 additions & 0 deletions src/loops/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
44 changes: 44 additions & 0 deletions tests/loops/dynamic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task>[] = [
{
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<Task, Out> = () => 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<Task, Out>({ 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)
})
})
Loading