Class: Pikuri::SubAgent::Extension

Inherits:
Object
  • Object
show all
Includes:
Agent::Extension
Defined in:
lib/pikuri/sub_agent/extension.rb

Overview

An Agent::Extension that wires the agent tool onto a parent agent from a list of Persona instances. The canonical opt-in for sub-agents — same shape as Pikuri::Skill::Extension and Mcp::Extension.

Usage

Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
c.add_sub_agent_tool Pikuri::Tool::WebSearch.build
c.add_sub_agent_tool Pikuri::Tool::WEB_SCRAPE
c.add_sub_agent_tool Pikuri::Tool::FETCH
c.add_extension Pikuri::SubAgent::Extension.new(
  personas: [Pikuri::SubAgent::RESEARCHER]
)
end

Either Agent::Configurator#add_tool or Agent::Configurator#add_sub_agent_tool satisfies a persona's tool_names entry — the difference is whether the parent LLM also gets the tool. Use add_sub_agent_tool for tools you want only the sub-agent to be able to call (this is the lethal-trifecta defense for network tools — see SECURITY.md §"Defense: capability boundaries via sub-agents").

The MCP-shape configure/bind split: configure(c) validates every persona's tool_names against tools already on the Configurator (a host-side bug to catch at boot, not first LLM call) and contributes the <available_agents> snippet; bind(ctx) constructs the SubAgentTool over the parent's Agent::ExtensionContext. Sub-agents don't inherit extensions, so bind fires for the parent only.

The constructor raises on two personas sharing a name (they'd be indistinguishable to the model and a quiet config bug) — fail fast rather than silently shadow.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(personas:, confirmer: nil) ⇒ Extension

Returns a new instance of Extension.

Parameters:

  • personas (Array<Persona>)

    personas the LLM may spawn via the agent tool. Must contain at least one entry; names must be unique across the list.

  • confirmer (Pikuri::Workspace::Confirmer, nil) (defaults to: nil)

    optional gate threaded into SubAgentTool; when present, each delegation's task is confirmed before dispatch. nil (default) delegates un-gated. Hosts wire one when a persona reaches the network and the parent holds private data.

Raises:

  • (ArgumentError)

    if personas is empty, contains a non-Persona, or two entries share a name



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/pikuri/sub_agent/extension.rb', line 53

def initialize(personas:, confirmer: nil)
  raise ArgumentError, 'personas: must contain at least one Persona' if personas.empty?

  @confirmer = confirmer

  @personas = {}
  personas.each do |persona|
    raise ArgumentError, "expected Pikuri::SubAgent::Persona, got #{persona.class}" \
      unless persona.is_a?(Persona)
    raise ArgumentError, "duplicate persona name #{persona.name.inspect} " \
                         'in personas: list' \
      if @personas.key?(persona.name)

    @personas[persona.name] = persona
  end
end

Instance Attribute Details

#personasHash{String=>Persona} (readonly)

Returns personas keyed by name, in declaration order.

Returns:

  • (Hash{String=>Persona})

    personas keyed by name, in declaration order.



72
73
74
# File 'lib/pikuri/sub_agent/extension.rb', line 72

def personas
  @personas
end

Instance Method Details

#bind(ctx) ⇒ void

This method returns an undefined value.

Construct the SubAgentTool over the parent's Agent::ExtensionContext and register it. Goes through Agent::ExtensionContext#add_raw_tool because the tool's execute closure captures the parent's tool list, which is final only by the time bind runs.

Parameters:

  • ctx (Pikuri::Agent::ExtensionContext)


114
115
116
117
118
# File 'lib/pikuri/sub_agent/extension.rb', line 114

def bind(ctx)
  sub_tool = SubAgentTool.new(ctx, personas: @personas, confirmer: @confirmer)
  ctx.add_raw_tool(sub_tool.to_ruby_llm_tool)
  nil
end

#configure(c) ⇒ void

This method returns an undefined value.

Validate every persona's tool_names against the union of the Configurator's regular and sub-agent-only tool pools, then append the <available_agents> snippet. So: call c.add_extension after the c.add_tool / c.add_sub_agent_tool calls the personas depend on, or the validation won't find them. (The SubAgentTool itself is installed in #bind, which needs the live parent to close over.)

Parameters:

  • c (Pikuri::Agent::Configurator)

Raises:

  • (ArgumentError)

    if any persona references a tool_names entry not registered on either c.tools or c.sub_agent_tools



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/pikuri/sub_agent/extension.rb', line 86

def configure(c)
  have = (c.tools + c.sub_agent_tools).map(&:name)
  @personas.each_value do |persona|
    missing = persona.tool_names - have
    next if missing.empty?

    raise ArgumentError,
          "persona #{persona.name.inspect} references unregistered tool(s) " \
          "#{missing.inspect}. Register them via c.add_tool or " \
          "c.add_sub_agent_tool before adding Pikuri::SubAgent::Extension. " \
          "Currently registered: #{have.inspect}."
  end

  nil
end

#system_prompt_snippetsArray<String>

Returns the <available_agents> catalog built from the wired persona list (fixed for the agent's lifetime).

Returns:

  • (Array<String>)

    the <available_agents> catalog built from the wired persona list (fixed for the agent's lifetime).



104
# File 'lib/pikuri/sub_agent/extension.rb', line 104

def system_prompt_snippets = [SubAgentTool.available_agents_snippet(@personas)]

#trifecta_contribution(tools) ⇒ Pikuri::Trifecta::Contribution

One child node per persona, resolving tool_names against tools exactly as SubAgentTool does at dispatch — the extension that owns the resolution owns the node, so the tree can never disagree with the toolset a sub-agent actually receives.

The gate on each edge is read from the injected confirmer's own posture, not from whether one was supplied: a --no-confirm wiring passes Workspace::Confirmer::AUTO_APPROVE, which answers blocks_on_human? == false, so the delegation channel scores :unreviewed and the parent's propagated egress re-hardens with nothing in the detector special-casing the flag.

Parameters:

  • tools (Array<Pikuri::Tool>)

Returns:

  • (Pikuri::Trifecta::Contribution)


134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/pikuri/sub_agent/extension.rb', line 134

def trifecta_contribution(tools)
  channel = @confirmer&.blocks_on_human? ? :human_reviewed : :unreviewed

  children = @personas.each_value.map do |persona|
    persona_tools = tools.select { |t| persona.tool_names.include?(t.name) }
    Pikuri::Trifecta::Node.new(
      label: persona.name,
      tool_legs: persona_tools.to_h { |t| [t.name, t.trifecta_legs] },
      channel_egress: channel
    )
  end

  Pikuri::Trifecta::Contribution.new(children: children)
end