Class: Pikuri::Skill::Extension
- Inherits:
-
Object
- Object
- Pikuri::Skill::Extension
- 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
-
#activation ⇒ Pikuri::Skill::Activation, Pikuri::Skill::Activation::Open
readonly
Which gated skills this conversation has in play.
- #catalog ⇒ Pikuri::Skill::Catalog readonly
- #root ⇒ Pathname readonly
Instance Method Summary collapse
-
#announce(skills, path) ⇒ void
Inject a promotion and emit Promoted.
-
#bind(ctx) ⇒ void
Capture the live agent's interloper so #schedule_load_skill and #announce can enqueue onto it, and the context #announce emits through.
-
#configure(c) ⇒ void
Register the
skilltool, and — when the agent can receive a promotion — the listener that fires one. -
#initialize(catalog:, root:) ⇒ Extension
constructor
A new instance of Extension.
-
#on_conversation_reset(_ctx) ⇒ void
Re-hide the gated skills.
-
#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
skilltool. -
#system_prompt_snippets ⇒ Array<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).
Constructor Details
#initialize(catalog:, root:) ⇒ Extension
Returns a new instance of Extension.
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
#activation ⇒ Pikuri::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.
72 73 74 |
# File 'lib/pikuri/skill/extension.rb', line 72 def activation @activation end |
#catalog ⇒ Pikuri::Skill::Catalog (readonly)
63 64 65 |
# File 'lib/pikuri/skill/extension.rb', line 63 def catalog @catalog end |
#root ⇒ Pathname (readonly)
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.
113 114 115 116 117 118 119 |
# File 'lib/pikuri/skill/extension.rb', line 113 def announce(skills, path) return if skills.empty? @interloper.(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.
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).
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.
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.)
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.(Pikuri::Skill::Renderer.format_skill(skill)) skill end |
#system_prompt_snippets ⇒ Array<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.
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 |