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
12 changes: 11 additions & 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.18.0",
"version": "0.19.0",
"description": "Reusable runtime lifecycle for domain-specific agents.",
"homepage": "https://github.com/tangle-network/agent-runtime#readme",
"repository": {
Expand Down Expand Up @@ -33,6 +33,16 @@
"types": "./dist/agent.d.ts",
"import": "./dist/agent.js",
"default": "./dist/agent.js"
},
"./loops": {
"types": "./dist/loops.d.ts",
"import": "./dist/loops.js",
"default": "./dist/loops.js"
},
"./profiles": {
"types": "./dist/profiles.d.ts",
"import": "./dist/profiles.js",
"default": "./dist/profiles.js"
}
},
"files": [
Expand Down
102 changes: 102 additions & 0 deletions src/loops/drivers/fanout-vote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @experimental
*
* FanoutVote driver — N parallel attempts in iteration 0, pick the highest-
* scoring valid output. No second iteration: the topology is "spawn N, score,
* pick winner". The kernel handles heterogeneous fanout via the
* `agentRuns: AgentRunSpec[]` form on `runLoop`.
*/

import { ValidationError } from '../../errors'
import type { DefaultVerdict, Driver, Iteration } from '../types'

export type FanoutVoteDecision = 'pick-winner' | 'fail'

/** @experimental */
export interface FanoutVoteScored<Task, Output> {
task: Task
output: Output
verdict?: DefaultVerdict
iterationIndex: number
agentRunName: string
}

/** @experimental */
export interface CreateFanoutVoteDriverOptions<Task, Output> {
/** Number of parallel attempts. Must be >= 1. */
n: number
/**
* Pick the winner from the scored set. Default: highest `verdict.score`
* among valid outputs (ties broken by smallest iteration index). When
* no valid outputs exist, returns `undefined` and `decide()` resolves
* to `'fail'`. The kernel still records winners structurally — this
* selector only feeds `decide()`'s pass/fail signal.
*/
selector?: (
scored: FanoutVoteScored<Task, Output>[],
) => FanoutVoteScored<Task, Output> | undefined
/** Stable identifier surfaced in trace events. Default `'fanout-vote'`. */
name?: string
}

/** @experimental */
export function createFanoutVoteDriver<Task, Output>(
options: CreateFanoutVoteDriverOptions<Task, Output>,
): Driver<Task, Output, FanoutVoteDecision> {
if (!Number.isFinite(options.n) || options.n < 1) {
throw new ValidationError(`createFanoutVoteDriver: n must be >= 1, got ${options.n}`)
}
const selector = options.selector ?? defaultSelector
return {
name: options.name ?? 'fanout-vote',
async plan(task, history) {
if (history.length === 0) return Array.from({ length: options.n }, () => task)
return []
},
decide(history) {
const scored = scoreIterations(history)
return selector(scored) ? 'pick-winner' : 'fail'
},
}
}

function defaultSelector<Task, Output>(
scored: FanoutVoteScored<Task, Output>[],
): FanoutVoteScored<Task, Output> | undefined {
const valid = scored.filter((entry) => entry.verdict?.valid === true)
if (valid.length === 0) return undefined
return [...valid].sort(
(a, b) =>
(b.verdict?.score ?? 0) - (a.verdict?.score ?? 0) || a.iterationIndex - b.iterationIndex,
)[0]
}

function scoreIterations<Task, Output>(
iterations: ReadonlyArray<Iteration<Task, Output>>,
): FanoutVoteScored<Task, Output>[] {
const out: FanoutVoteScored<Task, Output>[] = []
for (const iter of iterations) {
if (iter.output === undefined || iter.error) continue
out.push({
task: iter.task,
output: iter.output,
verdict: iter.verdict,
iterationIndex: iter.index,
agentRunName: iter.agentRunName,
})
}
return out
}

/**
* Test helper: surface the per-iteration scored view a custom `selector`
* would receive. Exposed so consumers writing a custom selector can test it
* standalone without driving the full kernel.
*
* @experimental
*/
export function scoreFanoutVoteIterations<Task, Output>(
iterations: ReadonlyArray<Iteration<Task, Output>>,
): FanoutVoteScored<Task, Output>[] {
return scoreIterations(iterations)
}
79 changes: 79 additions & 0 deletions src/loops/drivers/refine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* @experimental
*
* Refine driver — single task per iteration, validator-gated.
*
* `plan` returns `[task]` (possibly transformed via `refineTask`) until the
* prior verdict is valid OR the local cap is hit, then `[]`.
* `decide` returns `'stop'` once the latest verdict is valid OR the cap is
* reached. The kernel's `maxIterations` is an orthogonal safety cap;
* whichever is lower wins.
*/

import { ValidationError } from '../../errors'
import type { DefaultVerdict, Driver, Iteration } from '../types'

export type RefineDecision = 'continue' | 'stop'

/** @experimental */
export interface CreateRefineDriverOptions<Task> {
/** Hard cap on iterations. Default 5. */
maxIterations?: number
/**
* Optional task transform applied each round based on the prior verdict.
* When omitted, the same task is replayed and the agent is expected to
* inspect the sandbox session state for prior attempts.
*/
refineTask?: (task: Task, prior: DefaultVerdict) => Task
/** Stable identifier surfaced in trace events. Default `'refine'`. */
name?: string
}

/** @experimental */
export function createRefineDriver<Task, Output>(
options: CreateRefineDriverOptions<Task> = {},
): Driver<Task, Output, RefineDecision> {
const maxIterations = options.maxIterations ?? 5
if (!Number.isFinite(maxIterations) || maxIterations <= 0) {
throw new ValidationError('createRefineDriver: maxIterations must be > 0')
}
const refineTask = options.refineTask
return {
name: options.name ?? 'refine',
async plan(task, history) {
if (history.length >= maxIterations) return []
if (history.length === 0) return [task]
const prior = history.at(-1)
if (!prior) return [task]
if (prior.verdict?.valid === true) return []
// Worker error: replay the same task so the agent can self-correct.
// The driver has no signal beyond `verdict`; only the validator
// controls "good enough".
if (!refineTask || !prior.verdict) return [prior.task]
return [refineTask(prior.task, prior.verdict)]
},
decide(history) {
const last = history.at(-1)
if (!last) return 'continue'
if (last.verdict?.valid === true) return 'stop'
if (history.length >= maxIterations) return 'stop'
return 'continue'
},
}
}

/**
* Test helper: select the last-valid iteration (or the last attempt if
* none passed). Mirrors the kernel's default selector ordering for refine
* topologies — the most recent successful attempt wins.
*
* @experimental
*/
export function refineWinnerIndex<Task, Output>(
iterations: ReadonlyArray<Iteration<Task, Output>>,
): number | undefined {
for (let i = iterations.length - 1; i >= 0; i -= 1) {
if (iterations[i]?.verdict?.valid) return i
}
return iterations.length > 0 ? iterations.length - 1 : undefined
}
49 changes: 49 additions & 0 deletions src/loops/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @experimental
*
* Driven-loop substrate. `runLoop` orchestrates around the sandbox SDK; it
* does not invent its own notion of "what an agent is". Each iteration is
* a `sandboxClient.create({ backend: { profile } })` + `box.streamPrompt`
* call. The driver owns topology; the validator owns scoring; the output
* adapter owns event-stream decode; the kernel owns iteration accounting,
* concurrency, abort, cost aggregation, and trace emission.
*/

// One-stop import: sandbox-SDK types consumers need to spell out an
// `AgentRunSpec` without importing `@tangle-network/sandbox` separately.
export type {
AgentProfile,
CreateSandboxOptions,
SandboxEvent,
SandboxInstance,
} from '@tangle-network/sandbox'
export type {
CreateFanoutVoteDriverOptions,
FanoutVoteDecision,
FanoutVoteScored,
} from './drivers/fanout-vote'
export { createFanoutVoteDriver, scoreFanoutVoteIterations } from './drivers/fanout-vote'
export type { CreateRefineDriverOptions, RefineDecision } from './drivers/refine'
export { createRefineDriver, refineWinnerIndex } from './drivers/refine'
export type { RunLoopOptions } from './run-loop'
export { runLoop } from './run-loop'
export type {
AgentRunSpec,
DefaultVerdict,
Driver,
ExecCtx,
Iteration,
LoopDecisionPayload,
LoopEndedPayload,
LoopIterationEndedPayload,
LoopIterationStartedPayload,
LoopResult,
LoopSandboxClient,
LoopStartedPayload,
LoopTraceEmitter,
LoopTraceEvent,
LoopWinner,
OutputAdapter,
ValidationCtx,
Validator,
} from './types'
Loading
Loading