Module: Insika::SubagentGraph

Defined in:
lib/insika/subagent_graph.rb

Overview

Definition-time integrity of the subagent delegation graph (RFC-0010 ยง4.4, item 21). A pure function over {id => [child_ids]}: detects CYCLES and computes the max delegation DEPTH, raising a typed SubagentError so the authoring Command (CreateAgent/UpdateAgent) fails cleanly and boot refuses a bad static set. Cycle + bounded depth are exactly Flue's definition-time guarantee (DelegationDepthExceededError + anti-circular) โ€” with the graph acyclic and depth <= cap, the runtime is provably bounded (the guard in Executor#run_subagent is only belt-and-suspenders for a graph that changed mid-run).

UNKNOWN child refs are treated as LEAVES (no outgoing edges), NOT an error: dynamic authoring must not break by creation order โ€” a not-yet-created child surfaces as a clean "not found" at runtime (run_subagent), not a boot failure.

Constant Summary collapse

DEFAULT_DEPTH_CAP =

Longest delegation chain allowed (root counts as depth 0; each spawn +1). Override with INSIKA_SUBAGENT_DEPTH_CAP.

5
DEFAULT_FANOUT_CAP =

Max children a single spawn_subagents fan-out may run โ€” also the concurrency bound (N concurrent LLM calls hit the provider rate limit + the per-agent token ceiling of item 33). Override with INSIKA_SUBAGENT_FANOUT_CAP.

8

Class Method Summary collapse

Class Method Details

.check_from(root, map, cap) ⇒ Object

DFS from root tracking the recursion stack (cycle) and the longest path (depth). A ref to an id absent from map is a leaf.



62
63
64
# File 'lib/insika/subagent_graph.rb', line 62

def check_from(root, map, cap)
  walk(root, map, cap, [], {})
end

.depth_capObject



29
30
31
# File 'lib/insika/subagent_graph.rb', line 29

def depth_cap
  int_env("INSIKA_SUBAGENT_DEPTH_CAP", DEFAULT_DEPTH_CAP)
end

.each_profile(profiles) ⇒ Object

Duck-typed enumeration: an Array of profiles, or a ProfileSource exposing #all. Anything else Enumerable is iterated as-is.



89
90
91
92
93
94
# File 'lib/insika/subagent_graph.rb', line 89

def each_profile(profiles)
  return profiles if profiles.is_a?(Array)
  return profiles.all if profiles.respond_to?(:all)

  Array(profiles)
end

.fan_out_capObject



33
34
35
# File 'lib/insika/subagent_graph.rb', line 33

def fan_out_cap
  int_env("INSIKA_SUBAGENT_FANOUT_CAP", DEFAULT_FANOUT_CAP)
end

.int_env(name, default) ⇒ Object



37
38
39
40
# File 'lib/insika/subagent_graph.rb', line 37

def int_env(name, default)
  raw = Insika::EnvSchema.read(name)
  raw && !raw.strip.empty? ? Integer(raw) : default
end

.normalize(hash) ⇒ Object



83
84
85
# File 'lib/insika/subagent_graph.rb', line 83

def normalize(hash)
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = Array(v).map(&:to_s) }
end

.to_map(profiles) ⇒ Object

Builds the => [child_ids] adjacency map. Absent/nil subagents => [].



52
53
54
55
56
57
58
# File 'lib/insika/subagent_graph.rb', line 52

def to_map(profiles)
  return normalize(profiles) if profiles.is_a?(Hash)

  each_profile(profiles).each_with_object({}) do |p, acc|
    acc[p.id.to_s] = Array(p.subagents).map(&:to_s)
  end
end

.validate!(profiles, cap: depth_cap) ⇒ Object

Validates the whole set. profiles is anything enumerable of profiles responding to #id and #subagents (an Array or a ProfileSource#all result), OR a plain {id => [child_ids]} Hash. Raises on the FIRST violation.



45
46
47
48
49
# File 'lib/insika/subagent_graph.rb', line 45

def validate!(profiles, cap: depth_cap)
  map = to_map(profiles)
  map.each_key { |root| check_from(root, map, cap) }
  map
end

.walk(node, map, cap, stack, memo) ⇒ Object

Returns the max depth of the subtree rooted at node. stack is the current path (cycle detection); memo caches finished subtrees.

Raises:



68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/insika/subagent_graph.rb', line 68

def walk(node, map, cap, stack, memo)
  raise SubagentCycleError.new(cycle: stack + [node]) if stack.include?(node)
  return memo[node] if memo.key?(node)

  children = map[node] || [] # unknown ref => leaf
  depth = if children.empty?
            0
          else
            1 + children.map { |c| walk(c, map, cap, stack + [node], memo) }.max
          end
  raise SubagentDepthExceeded.new(depth: depth, cap: cap) if depth > cap

  memo[node] = depth
end