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: 2 additions & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export {
buildSteerContext,
type CreateScopeAnalystOptions,
createScopeAnalyst,
type RegistryAnalyzeProjection,
registryScopeAnalyst,
} from './personify/analyst'
export {
fanout,
Expand Down
46 changes: 45 additions & 1 deletion src/runtime/personify/analyst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
* throws; there is no silent empty-findings path that would let a combinator steer on nothing.
*/

import type { AnalystFinding } from '@tangle-network/agent-eval'
import type { AnalystFinding, AnalystRunInputs } from '@tangle-network/agent-eval'
import type { AnalystRegistryLike } from '../../analyst-loop/types'
import { AnalystError, PlannerError } from '../../errors'
import type { Agent, Budget, DefaultVerdict, NodeId, Scope, Settled } from '../supervise/types'
import { stringifySafe } from '../util'
Expand Down Expand Up @@ -172,6 +173,49 @@ function readAnalystFindings<D>(settled: Settled<Outcome<D>>): ReadonlyArray<Ana
return out as ReadonlyArray<AnalystFinding>
}

// ── The panel-of-analysts adapter — N analyst KINDS merged into one ScopeAnalyst ───────

/**
* Project a `ScopeAnalyzeInput` into the `AnalystRegistry.run` arguments. The registry runs over a
* `runId` + `AnalystRunInputs` (a trace store / run record / artifact dir), NOT in-memory scope
* settlements — so the CALLER owns the projection from the combinator's drained children to the
* registry's inputs (e.g. the trace store the run already wrote). This adapter never invents that
* bridge; it only runs the projected inputs and firewalls the merged findings.
*/
export interface RegistryAnalyzeProjection {
readonly runId: string
readonly inputs: AnalystRunInputs
/** Optional `run` opts (e.g. `priorFindings`) forwarded verbatim to the registry. */
readonly opts?: Parameters<AnalystRegistryLike['run']>[2]
}

/**
* A `ScopeAnalyst` backed by an `AnalystRegistry` — the panel-of-analysts seam. The registry merges
* N analyst KINDS into one `AnalystRunResult.findings`; `analyze` runs it over the caller-projected
* `{ runId, inputs }` and pipes the merged findings through the SAME `assertTraceDerivedFindings`
* firewall `createScopeAnalyst` uses (single-sourced selector≠judge). Distinct from `panel()`
* (judges-vs-one-artifact) — this is analysts-over-a-trace, the diagnosis side of the wire.
*
* Fail loud: a registry that throws propagates; a judge-derived finding aborts via the firewall.
* The projection is the caller's (`buildInputs`) — if the scope settlements do not cleanly map to
* the registry's `AnalystRunInputs`, that is a caller-side contract gap, surfaced there, not papered
* over with a fabricated input here.
*/
export function registryScopeAnalyst<D>(
registry: AnalystRegistryLike,
buildInputs: (input: ScopeAnalyzeInput<D>) => RegistryAnalyzeProjection,
): ScopeAnalyst<D> {
return {
async analyze(input: ScopeAnalyzeInput<D>): Promise<ReadonlyArray<AnalystFinding>> {
const projection = buildInputs(input)
const result = await registry.run(projection.runId, projection.inputs, projection.opts)
const findings = result.findings
assertTraceDerivedFindings(findings)
return findings
},
}
}

// ── The single firewalled steer surface every combinator funnels through ──────────────

/**
Expand Down
38 changes: 30 additions & 8 deletions src/runtime/personify/combinators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,10 @@ export function fanout<Task, Item, D>(
* the deployable stop. The conserved pool IS the loop bound: once `spawn` fails closed the loop
* stops. A loop that exhausted the pool without `until` ever satisfying is a concrete blocker.
*
* Findings are threaded through the `SteerContext` firewall in the analyst seam (`analyst.ts`);
* absent a wired analyst on this surface the firewall stays dormant and `until` is consulted with
* an empty findings array — never a fabricated finding (fail-loud honesty over a silent default).
* When `ctx.analyst` is set, each round runs it over the children settled so far and steers
* `until` on the resulting trace-derived findings (the analyst spawns into THIS scope, so its
* compute is conserved-pooled — equal-k holds by construction). Absent an analyst the findings
* argument is the empty array — never a fabricated finding (fail-loud honesty over a silent default).
*/
export function loopUntil<Task, State, D>(
seed: State,
Expand All @@ -175,6 +176,7 @@ export function loopUntil<Task, State, D>(
async act(task, scope): Promise<Outcome<D>> {
let state: LoopUntilState<State> = { round: 0, value: seed }
const blockers: string[] = []
const settledSoFar: Settled<Outcome<D>>[] = []
for (;;) {
const label = spec.label ? spec.label(state.round) : `step:${state.round}`
const child = ctx.spawnChild(label, ctx.persona.root)
Expand All @@ -188,8 +190,14 @@ export function loopUntil<Task, State, D>(
}
const settled = await drainOne(scope, label)
if (settled.kind === 'down') blockers.push(blockerFromDown(settled))
settledSoFar.push(settled)
state = spec.fold(state, settled)
const reached = spec.until(state, [])
// Wired analyst ⇒ steer `until` on trace-derived findings; absent ⇒ the dormant empty
// default (the analyst spawns into THIS scope, so its compute is conserved-pooled).
const findings = ctx.analyst
? await ctx.analyst.analyze({ task, settledSoFar, nodeId: scope.view.root })
: []
const reached = spec.until(state, findings)
if (reached) return reached
state = { round: state.round + 1, value: state.value }
}
Expand Down Expand Up @@ -316,9 +324,14 @@ export function verify<Task, Candidate, D>(
* never a child's raw `verdict` — and the default gate (`flatWidenGate`) never widens, so the R2
* firewall stays dormant. Terminal selection is `spec.synthesize` over every settled lineage.
*
* No analyst is wired on this frozen surface, so `decide` is consulted with an empty findings
* array; a flat gate ignores it. A non-flat gate that wants findings reads them through the
* `SteerContext` firewall the analyst seam owns — never fabricated here.
* When `ctx.analyst` is set, `decide` is consulted with that round's trace-derived findings;
* absent an analyst the findings argument is the empty array a flat gate ignores. The analyst
* spawns into THIS scope (conserved-pooled, so equal-k holds). Streaming caveat: a wired analyst
* drains its own child off the SHARED cursor by id-match, so on a NON-flat gate (which spawns
* widen children that are live concurrently) the analyst can consume a sibling's settlement before
* the widen loop sees it. The shipped default (`flatWidenGate`) never widens, so no widen child is
* ever live when the analyst runs and the wire is exact; a non-flat gate must drive the analyst on
* a scope whose siblings are quiesced, or read findings without the shared-cursor drain.
*/
export function widen<Task, Seed, D>(spec: WidenSpec<Seed, D>): CombinatorShape<Task, D> {
return (ctx: ShapeContext<D>): Agent<Task, Outcome<D>> => ({
Expand All @@ -342,7 +355,16 @@ export function widen<Task, Seed, D>(spec: WidenSpec<Seed, D>): CombinatorShape<
let widenIndex = 0
for (let s = await scope.next(); s !== null; s = await scope.next()) {
gathered.push(s)
const decision: WidenDecision<D> = spec.gate.decide(s, [], scope.budget)
// Wired analyst ⇒ steer the gate on trace-derived findings; absent ⇒ the dormant empty
// default the flat gate ignores. The analyst spawns into THIS scope (conserved-pooled).
const findings = ctx.analyst
? await ctx.analyst.analyze({
task: _task,
settledSoFar: gathered,
nodeId: scope.view.root,
})
: []
const decision: WidenDecision<D> = spec.gate.decide(s, findings, scope.budget)
if (decision.kind !== 'widen') continue
const label = `widen:${widenIndex}`
widenIndex += 1
Expand Down
10 changes: 8 additions & 2 deletions src/runtime/personify/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type {
ShapeBudget,
ShapeContext,
} from './types'
import type { ScopeAnalyst } from './wave-types'

// ── definePersona ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -80,10 +81,15 @@ export function definePersona<D = unknown>(input: DefinePersonaInput<D>): Person
* profile. The shape never touches the registry — resolution stays single-sourced in the
* scope/registry the supervisor owns.
*/
export function createShapeContext<D>(persona: Persona<D>, budget: ShapeBudget): ShapeContext<D> {
export function createShapeContext<D>(
persona: Persona<D>,
budget: ShapeBudget,
analyst?: ScopeAnalyst<D>,
): ShapeContext<D> {
return {
persona,
budget,
...(analyst ? { analyst } : {}),
spawnChild(name, spec): Agent<unknown, Outcome<D>> {
// The wrapped agent is SPAWNED, not run — the resolved Executor drives it. `act`
// is never invoked by the keystone for a spawned child; it throws if mis-used as a
Expand Down Expand Up @@ -124,7 +130,7 @@ export async function runPersonified<Task, D>(
const { persona } = options
const shape = resolveShape<Task, D>(options.shape)
const shapeBudget = resolveShapeBudget(options.budget, options.shapeBudget)
const ctx = createShapeContext(persona, shapeBudget)
const ctx = createShapeContext(persona, shapeBudget, options.analyst)
const rootAgent = shape(ctx)

const executors = personaRegistry(persona)
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/personify/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
SpawnJournal,
SupervisedResult,
} from '../supervise/types'
import type { ScopeAnalyst } from './wave-types'

// ── The deliverable contract every shape synthesizes into ──────────────────────

Expand Down Expand Up @@ -174,6 +175,9 @@ export interface ShapeContext<D = unknown> {
/** Derive a child `AgentSpec` from the persona's root spec with an overridden profile —
* the seam a shape uses to give a worker a narrower role/prompt than the root persona. */
childSpec(profile: AgentProfile, harness?: BackendType | null): AgentSpec
/** The scope analyst (selector≠judge firewall) the combinator steers from. Absent ⇒ the
* dormant default (empty findings → gates read deliverables/state only). */
readonly analyst?: ScopeAnalyst<D>
}

/**
Expand Down Expand Up @@ -234,6 +238,9 @@ export interface RunPersonifiedOptions<Task, D> {
readonly handle?: RootHandle<Outcome<D>>
readonly now?: () => number
readonly signal?: AbortSignal
/** Optional scope analyst threaded into the shape's ShapeContext so loopUntil/widen steer
* on trace-derived findings instead of the dormant empty default. */
readonly analyst?: ScopeAnalyst<D>
}

/** The composed run signature. */
Expand Down
Loading
Loading