Class: Mistri::SubAgent

Inherits:
Object
  • Object
show all
Defined in:
lib/mistri/sub_agent.rb

Overview

Delegation with a clean context: a child agent runs on its own session (the caller's store, linked in the caller's transcript), and only its final answer returns to the parent — exploration never fills the parent's window. Compaction rescues a full context after the fact; spawning avoids filling it in the first place. A child session is its own single-provider session, so delegating to a cheaper model is the sanctioned way to mix models.

Two shapes on one mechanism. A named specialist the host curates:

researcher = Mistri::SubAgent.new(
name: "researcher", description: "Answers factual questions.",
provider: Mistri.provider("claude-haiku-4-5-20251001"),
system: "Research. Report findings only.", tools: [fetch_page],
)
agent = Mistri::Agent.new(provider:, tools: [researcher.tool])

and the open spawn tool, where the model composes each worker: names it, writes its instructions, picks a tool subset, and may pick a model:

spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])

Children never receive a spawn tool: delegation is one level deep by construction. Parallel fan-out costs nothing — several spawn calls in one turn run concurrently on the executor pool.

Child events forward into the parent's stream tagged with origin ("researcher#ab12cd34"; nesting of named specialists joins with ">"). Approval-gated tools cannot ride inside a child: a child answers its parent synchronously and cannot fire-and-forget suspend, so statically gated tools are refused at construction and a child that suspends at runtime is denied and reported in band. Gate the delegation itself instead (needs_approval: on the definition or spawner).

Constant Summary collapse

SPAWNER_DESCRIPTION =
"Delegate a self-contained task to a focused child agent with a clean " \
"context. The child starts blank: give it complete instructions and " \
"every fact it needs. Use it to keep exploration out of your own " \
"context, and spawn several in one turn to fan out independent " \
"angles. Only the child's final answer comes back."

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, description:, provider:, system: nil, tools: [], schema: nil, **agent_options) ⇒ SubAgent

schema: makes the specialist answer in validated JSON (task mode underneath) — fan-out children then return a uniform shape the parent synthesizes instead of five styles of prose.



50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/mistri/sub_agent.rb', line 50

def initialize(name:, description:, provider:, system: nil, tools: [], schema: nil,
               **agent_options)
  SubAgent.forbid_gated!(tools)
  @name = name.to_s
  @description = description
  @provider = provider
  @system = system
  @tools = tools
  @schema = schema
  @agent_options = agent_options
  @gate = agent_options.delete(:needs_approval) || false
end

Instance Attribute Details

#descriptionObject (readonly)

Returns the value of attribute description.



45
46
47
# File 'lib/mistri/sub_agent.rb', line 45

def description
  @description
end

#nameObject (readonly)

Returns the value of attribute name.



45
46
47
# File 'lib/mistri/sub_agent.rb', line 45

def name
  @name
end

Class Method Details

.forbid_gated!(tools) ⇒ Object

Raises:



126
127
128
129
130
131
132
133
# File 'lib/mistri/sub_agent.rb', line 126

def forbid_gated!(tools)
  gated = tools.select { |tool| statically_gated?(tool) }
  return if gated.empty?

  raise ConfigurationError,
        "approval-gated tools cannot run inside a sub-agent " \
        "(#{gated.map(&:name).join(", ")}); gate the delegation instead"
end

.run_child(label:, provider:, system:, tools:, task:, context:, schema: nil, **agent_options) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/mistri/sub_agent.rb', line 109

def run_child(label:, provider:, system:, tools:, task:, context:, schema: nil,
              **agent_options)
  store = context.session ? context.session.store : Stores::Memory.new
  child = Session.new(store: store)
  context.session&.append("subagent", "name" => label, "session_id" => child.id)
  agent = Agent.new(provider: provider, session: child, system: system,
                    tools: tools, **agent_options)
  origin = "#{label}##{child.id[0, 8]}"
  emit = ->(event) { forward(event, origin, context) }
  result = if schema
             agent.task(task, schema: schema, signal: context.signal, &emit)
           else
             agent.run(task, signal: context.signal, &emit)
           end
  answer(result, label, child)
end

.spawner(provider:, tools: [], models: [], needs_approval: false, **agent_options) ⇒ Object

The open spawn tool over a pool of tools the host allows children to use. The model may name the worker (a display label riding origins and the transcript link) and grant a tool subset by name; models: is the host's allowlist of child model ids — without one, no model choice is offered at all, so a hallucinated id can never construct a provider or land children on an expensive model.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/mistri/sub_agent.rb', line 92

def spawner(provider:, tools: [], models: [], needs_approval: false, **agent_options)
  forbid_gated!(tools)
  if tools.any? { |tool| tool.name == "spawn_agent" }
    raise ConfigurationError, "the spawn tool never goes in its own pool"
  end

  schema = spawner_schema(tools, models, default_model(provider))
  Tool.define("spawn_agent", SPAWNER_DESCRIPTION,
              needs_approval: needs_approval, schema: schema) do |args, context|
    run_child(label: child_label(args["name"]),
              provider: child_provider(provider, args["model"], models),
              system: args.fetch("instructions"),
              tools: pick(tools, args["tools"]),
              task: args.fetch("task"), context: context, **agent_options)
  end
end

Instance Method Details

#run_child(task, context) ⇒ Object



79
80
81
82
83
# File 'lib/mistri/sub_agent.rb', line 79

def run_child(task, context)
  SubAgent.run_child(label: @name, provider: @provider, system: @system,
                     tools: @tools, task: task, context: context, schema: @schema,
                     **@agent_options)
end

#toolObject

The delegate tool: each call runs a fresh child and answers with its final text, plus session_id on the ui channel so a host can link the child's transcript.



66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/mistri/sub_agent.rb', line 66

def tool
  sub = self
  blurb = "#{@description} Runs as a focused sub-agent with a clean " \
          "context: give it complete instructions, it starts blank."
  Tool.define(@name, blurb, needs_approval: @gate,
                            schema: lambda {
                              string :task, "Complete instructions for the sub-agent",
                                     required: true
                            }) do |args, context|
    sub.run_child(args.fetch("task"), context)
  end
end