Class: Pikuri::Skill::Extension

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

Overview

An Agent::Extension that auto-wires the Agent Skills standard onto an agent: appends the catalog's <available_skills> block to the system prompt and registers the skill tool so the LLM can load skill bodies on demand. Empty catalog ⇒ no-op (no snippet, no tool).

Usage

catalog = Pikuri::Skill::Catalog::Bundled.new(
search_bases: [project_root, Dir.home]
)
Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
c.add_extension Pikuri::Skill::Extension.new(catalog: catalog, root: project_root)
end

This contributes no trifecta_contribution of its own, and that is not an omission: the prompt block and #schedule_load_skill carry the same bundles' bytes the skill tool does, so they ride its untrusted leg, which Catalog#untrusted_level grades over the whole stored set.

configure appends the prompt snippet + skill tool (both inherited verbatim by sub-agents via the tool snapshot); bind additionally captures the parent agent's interloper so a host can load a skill on its own initiative via #schedule_load_skill — the UI-driven counterpart of the skill tool. Main-agent-only: sub-agents don't inherit extensions.

It also owns this conversation's Activation — which +paths:+-gated skills have fired — and clears it when the host clears the conversation.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(catalog:, root:) ⇒ Extension

Returns a new instance of Extension.

Parameters:

  • catalog (Pikuri::Skill::Catalog)

    the catalog of skills the agent may load. Required.

  • root (Pathname)

    the workspace root a touched path is resolved against before it is matched against a skill's paths:. Absolute, because that path is model-supplied and must never be read as relative to whatever the process's cwd happens to be.

Raises:

  • (ArgumentError)

    if root is not an absolute Pathname



51
52
53
54
55
56
57
58
59
60
# File 'lib/pikuri/skill/extension.rb', line 51

def initialize(catalog:, root:)
  unless root.is_a?(Pathname) && root.absolute?
    raise ArgumentError, "root must be an absolute Pathname, got #{root.inspect}"
  end

  @catalog = catalog
  @root = root
  @activation = Pikuri::Skill::Activation.new(catalog: catalog)
  @gate_armed = catalog.list.any?(&:gated?)
end

Instance Attribute Details

#activationPikuri::Skill::Activation, Pikuri::Skill::Activation::Open (readonly)

Returns which gated skills this conversation has in play. #configure downgrades it to an Activation::Open — no gate at all — when the agent has no interloper to carry a promotion.

Returns:



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

def activation
  @activation
end

#catalogPikuri::Skill::Catalog (readonly)



63
64
65
# File 'lib/pikuri/skill/extension.rb', line 63

def catalog
  @catalog
end

#rootPathname (readonly)

Returns:

  • (Pathname)


66
67
68
# File 'lib/pikuri/skill/extension.rb', line 66

def root
  @root
end

Instance Method Details

#announce(skills, path) ⇒ void

This method returns an undefined value.

Inject a promotion and emit Promoted. The listener's callback; a host driving activation itself may call it too.

Parameters:



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

def announce(skills, path)
  return if skills.empty?

  @interloper.inject_system_message(Pikuri::Skill::Renderer.format_promotion(skills))
  @ctx&.emit_event(Pikuri::Skill::Promoted.new(skills: skills, path: path))
  nil
end

#bind(ctx) ⇒ void

This method returns an undefined value.

Capture the live agent's interloper so #schedule_load_skill and #announce can enqueue onto it, and the context #announce emits through.

Parameters:

  • ctx (Pikuri::Agent::ExtensionContext)


101
102
103
104
105
# File 'lib/pikuri/skill/extension.rb', line 101

def bind(ctx)
  @interloper = ctx.agent.interloper
  @ctx = ctx
  nil
end

#configure(c) ⇒ void

This method returns an undefined value.

Register the skill tool, and — when the agent can receive a promotion — the listener that fires one. No-op when the catalog is empty (the <available_skills> snippet is contributed by #system_prompt_snippets).

Parameters:

  • c (Pikuri::Agent::Configurator)


81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/pikuri/skill/extension.rb', line 81

def configure(c)
  return if @catalog.empty?

  if c.tools.any?(Pikuri::Skill::SkillTool)
    raise 'Pikuri::Skill::SkillTool cannot be pre-registered (in tools: or via c.add_tool) ' \
          'when adding Pikuri::Skill::Extension — the extension auto-registers it from the catalog.'
  end

  arm_gate(c.interloper)
  c.add_tool(Pikuri::Skill::SkillTool.new(activation: @activation))
  c.add_listener(path_activation_listener) if gate_armed?
  nil
end

#on_conversation_reset(_ctx) ⇒ void

This method returns an undefined value.

Re-hide the gated skills. The clear dropped their promotions from the log, so the next matching touch has to announce them again.

Parameters:

  • ctx (Pikuri::Agent::ExtensionContext)

    unused; protocol signature.



171
172
173
174
# File 'lib/pikuri/skill/extension.rb', line 171

def on_conversation_reset(_ctx)
  @activation.reset
  nil
end

#schedule_load_skill(name) ⇒ Pikuri::Skill::Catalog::Skill

Schedule a skill for injection into the conversation on the host's own initiative — the UI-driven counterpart of the skill tool. Looks name up in the catalog, renders its body identically to the tool, and enqueues it on the interloper as a reference block.

ext   = Pikuri::Skill::Extension.new(catalog: catalog, root: project_root)
agent = Pikuri::Agent.new(..., interloper: interloper) { |c| c.add_extension ext }
ext.schedule_load_skill('pdf-tools')    # enqueue only — nothing runs yet
agent.run_loop(user_message: nil)        # bare turn: the agent reacts to the skill

IMPORTANT: this only enqueues. Nothing happens until the host runs the loop — Agent#run_loop drains the injection at turn start, before the first completion. Enqueue-only keeps the call non-blocking and thread-safe (the interloper is +Mutex+-guarded); it never touches the loop or a worker thread. Two host choices, by how the loop is then run:

  • bare load — run_loop(user_message: nil): the agent reacts to the skill alone and says something.
  • load + message — run_loop(user_message: text): the skill block and the message both land before the model responds.

Either way the block sits positionally where it was loaded, on every provider — a one-shot, exactly like the skill tool's observation. (Agent#append_reference_block is what makes that uniform.)

Parameters:

  • name (String)

    a skill in play; the UI enumerates them, so a miss is a host bug. A +paths:+-gated skill whose path has not fired is not in play and is refused exactly as an unknown name is — the gate is the default at every surface, the host's included.

Returns:

Raises:

  • (KeyError)

    if name is not in play

  • (RuntimeError)

    if the agent was built without an interloper: (nowhere to enqueue)



155
156
157
158
159
160
161
162
163
164
# File 'lib/pikuri/skill/extension.rb', line 155

def schedule_load_skill(name)
  skill = @activation.get(name)
  raise KeyError, "no skill named #{name.inspect} in the catalog" if skill.nil?

  raise 'schedule_load_skill needs an interloper — construct the Agent with `interloper:`' \
    if @interloper.nil?

  @interloper.inject_system_message(Pikuri::Skill::Renderer.format_skill(skill))
  skill
end

#system_prompt_snippetsArray<String>

The <available_skills> catalog block, carrying every ungated skill and no hint that anything is missing (empty array when that leaves nothing to say). Renderer.format_catalog returns leading blank lines; the Agent joins sections with \n\n, so lstrip avoids doubling them.

Deliberately reads the catalog rather than the Activation: a promotion belongs in the log, not the prompt, and Agent#clear_conversation re-assembles the prompt before it calls #on_conversation_reset — a snippet built from the activation set would rebuild carrying activations that are about to be cleared, advertising skills the skill tool would then refuse.

Returns:

  • (Array<String>)


189
190
191
192
193
194
# File 'lib/pikuri/skill/extension.rb', line 189

def system_prompt_snippets
  return [] if @catalog.empty?

  block = Pikuri::Skill::Renderer.format_catalog(advertised)
  block.empty? ? [] : [block.lstrip]
end