graph

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Fan-out dispatch (docs/v0.3-plan.md W3): N analysts run concurrently over the same incident, one synthesis specialist merges what they return into a single report.

START → fan_out (DynamicNode) → run__synthesis
            └─ parallelagent → branch_<name> (workflowagent)
                                   └─ run_<name> → <name>

This is the narrow shape, not the general run_shape_fan_out_fan_in: the branch set is the roster, fixed at construction, and remediation stays sequential and post-synthesis. Nothing in a branch can mutate — see BuildFanout's construction check and the reason for it below.

Why parallelagent and not ParallelWorker

workflow.ParallelWorker is the obvious primitive and it is the wrong one for a roster of agents. It suppresses every event a branch emits that is not an output event (its own doc says so; runWrappedOnce keeps only extractOutput hits). An LLM agent's working memory is the session event list — internal/llminternal/contents_processor rebuilds req.Contents from ctx.Session().Events() on every model call, and the only thing that puts an event there is the RUNNER appending what was yielded up to it. Suppress the yield and the agent cannot see its own tool results: call #2 gets the same prompt as call #1, so a tool-using analyst loops until something cancels it. That is not a corner case, it is every analyst that reads a cluster. TestBranchAgentSeesItsToolResults pins the behaviour and TestFanoutSubstrate records the mechanism.

parallelagent funnels each sub-agent's events upward instead, and blocks the sub-agent until the parent has appended the event ("Signal sub-agent that event processing (including session append) is complete"). Branch isolation is preserved by branch tagging rather than by suppression: each sub-agent runs under branch "<fan>.<branch_name>" and the history filter admits only events on its own branch prefix, so analysts still cannot read each other.

Two composition facts make it fit:

  • A Task specialist cannot be a parallelagent sub-agent directly. parallelagent calls agent.Run, and Task-mode completion lives on the AgentNode/RunNode path, so finish_task returns and the agent keeps going. Each branch is therefore its own single-node workflowagent whose DynamicNode calls workflow.RunNode — legal because DynamicNode.Run installs its own sub-scheduler.
  • parallelagent runs every sub-agent at once. fanout.max_concurrency is enforced by mast, with a per-activation semaphore the branches acquire before their model call (see branchSemaphores).

Because branch events now reach the runner, two things that were false under ParallelWorker are true here: per-specialist budget scopes bite inside a branch (the meter buckets by event author over the runner's stream), and a branch's tool calls are in the event log where crash recovery can see them.

The construction-time refusal of mutating analysts (W3.3) stays, for the reason that survives that change: every branch runs BEFORE the one approval gate this shape has, which sits after synthesis. A mutating analyst is a mutation no operator was offered the chance to refuse — concurrently, N of them.

Package graph assembles the workload's triage flow as an explicit ADK v2 workflow graph — the LLM-as-router shape from docs/workflow-scaffolding-design.md (#7), as sketched in docs/triage-demo-plan.md:

START → classify (SingleTurn AgentNode) → route_by_reason
          ├─ StringRoute(<reason>) → run_<reason> (DynamicNode → Task specialist)
          └─ Default              → run__fallback

Spike-2 finding (supersedes the spike-1 comment in pkg/router): runner.Runner does NOT require the root agent to be a Chat-mode LlmAgent. The Chat-mode check applies only when the root IS an LlmAgent; a workflowagent-wrapped graph takes the runner's generic (non-LlmAgent) root path and works as a root agent directly. See adk/v2 runner/runner.go (isLlmAgent branch) and examples/workflow/routing/llm, which uses a workflowagent root.

Task-mode specialists cannot be static graph nodes, so each routed branch is a DynamicNode whose body invokes the specialist's AgentNode via workflow.RunNode — the sanctioned dynamic-invocation pattern (see adk/v2 examples/workflow/dynamic/llm).

Index

Constants

View Source
const BranchPrefix = "branch_"

BranchPrefix names the per-analyst wrapper agent. It shows up in the branch tag ("<workload>_fan.branch_<analyst>") and therefore in the event log, so it is exported for tests and for anyone reading a session back.

View Source
const DefaultMaxConcurrency = 4

DefaultMaxConcurrency bounds analyst branches when the bundle names no fanout.max_concurrency. Four is a floor on usefulness rather than a tuned number: enough that the shape is visibly concurrent, low enough that a roster of a dozen analysts does not open a dozen simultaneous provider connections by default.

View Source
const FallbackName = "_fallback"

FallbackName is the specialist that handles reasons the classifier can't map to a per-failure-mode specialist. Required in graph mode.

View Source
const SynthesisInterruptID = "approve-" + SynthesisName

SynthesisInterruptID is the change-safety-gate interrupt the merged report parks on when the bundle sets hitl.require_approval. One gate per run, after synthesis: an analyst branch never parks. A branch is a nested workflowagent with its own scheduler, so an interrupt raised inside one is that scheduler's to resolve and the outer graph has no pause to record — which is why the branch tool check refuses request_operator_input outright rather than trusting an analyst not to call it.

View Source
const SynthesisName = "_synthesis"

SynthesisName is the specialist that merges the analysts' findings into one report. Required in fan-out dispatch, and named by convention for the same reason FallbackName is: the shape needs a distinguished member of the roster, and a reserved name keeps that out of the bundle schema.

Variables

This section is empty.

Functions

func Build

func Build(cfg Config) (adkagent.Agent, error)

Build assembles the graph and wraps it as a runnable root agent via workflowagent.New.

func BuildFanout added in v0.3.0

func BuildFanout(cfg FanoutConfig) (adkagent.Agent, error)

BuildFanout assembles the fan-out graph and wraps it as a runnable root agent.

It refuses to build a roster whose analysts can mutate. That check is deliberately strict about what "can mutate" means: an allowlist that does not enumerate its tools is not a narrower grant but a wider one (pkg/specialists.filterToolsets passes the whole toolset through when a spec names no MCP servers, and the whole server through when it names a server with no tools), so an unenumerated analyst is refused alongside an explicitly-mutating one.

func SynthesisPrompt added in v0.3.0

func SynthesisPrompt(f *Findings) string

SynthesisPrompt renders the merge specialist's input. It reads Findings and nothing else — the payload-only contract in one function, so there is a single place to look when asking what synthesis can see.

Types

type Analyst added in v0.3.0

type Analyst struct {
	// Name is the specialist's name, used for the branch label and for
	// attributing findings in the synthesis input.
	Name string

	// Agent is the built specialist.
	Agent adkagent.Agent

	// Budget is the specialist's declared budget block; only
	// MaxWallclockSeconds is consumed here (→ NodeConfig.Timeout), the
	// same mapping graph dispatch makes. max_turns and max_cost_usd
	// reach the session meter as scopes instead (see nodeConfig), and
	// they do bite in a branch: internal/compose.MeterScopes buckets by
	// session.Event.Author over the runner's stream, and parallelagent
	// puts branch events on that stream. This was the one thing
	// ParallelWorker cost that was not visible from reading it — see
	// the package comment.
	Budget specialists.Budget

	// Tools is the specialist's declared allowlist, checked at
	// construction against the mutation predicate.
	Tools specialists.ToolAllowlist
}

Analyst is one fan-out branch: a Task specialist plus the two things BuildFanout has to check about it that the built agent no longer carries — its declared budget and its tool allowlist.

type Config

type Config struct {
	// Bundle is the workload definition; used for naming and roster
	// ordering.
	Bundle workload.Bundle

	// Classifier is the SingleTurn routing agent. Its one-shot output
	// is normalized into a route key by the route node.
	Classifier adkagent.Agent

	// Specialists is the roster of Task-mode specialists indexed by
	// spec name. Must contain FallbackName.
	Specialists map[string]Specialist
}

Config describes how to assemble the workflow-graph dispatch shape for a workload.

type FanoutConfig added in v0.3.0

type FanoutConfig struct {
	// Bundle is the workload definition; used for naming, HITL policy
	// and fanout.max_concurrency.
	Bundle workload.Bundle

	// Analysts are the concurrent branches, in the order their findings
	// are presented to synthesis. Must be non-empty.
	Analysts []Analyst

	// Synthesis is the specialist that merges the findings. Required.
	Synthesis Specialist

	// Mutating classifies a tool name. Nil means
	// effects.NewPredicate(nil) — mast's default-deny-unknown stance,
	// under which every MCP tool is mutating until the workload's
	// tool_catalog says otherwise.
	Mutating effects.Predicate
}

FanoutConfig describes how to assemble the fan-out shape.

type Finding added in v0.3.0

type Finding struct {
	Analyst string
	Payload any
}

Finding is one analyst's contribution: whatever its branch returned as an Output payload, and nothing else.

type Findings added in v0.3.0

type Findings struct {
	// Reported are the branches that returned a payload, in roster
	// order.
	Reported []Finding

	// Silent are the names of branches that returned no payload.
	Silent []string
}

Findings is what synthesis is allowed to see (W3.2): one Output payload per analyst that produced one, and nothing else. A branch's Output payload is its only contribution — an analyst that does work and returns nothing has, as far as this graph is concerned, done nothing, and Silent names it so that is visible rather than merely true.

type Specialist

type Specialist struct {
	// Agent is the built specialist.
	Agent adkagent.Agent

	// Budget is the specialist's declared budget block. Only
	// MaxWallclockSeconds is consumed here (→ NodeConfig.Timeout);
	// see nodeConfig for where the other fields are enforced.
	Budget specialists.Budget
}

Specialist pairs a built Task-mode agent with the budget bounds declared on its Spec, so Build can map per-specialist ceilings onto per-node ADK config without re-reading spec files.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL