pikuri-core 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +45 -67
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +100 -24
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +14 -3
@@ -2,18 +2,38 @@
2
2
 
3
3
  module Pikuri
4
4
  class Tool
5
- # Builder for the LLM-facing web-search tool. The search orchestration
6
- # lives in {Tool::Search::Engines}; this module owns only the {.build}
7
- # factory that wires a configured {Search::Engines} into a {Tool}.
5
+ # Builder for the LLM-facing web-search tool. Orchestration lives in
6
+ # {Tool::Search::Engines}; this owns only the {.build} factory wiring a
7
+ # configured {Search::Engines} into a {Tool}.
8
8
  #
9
- # Unlike the stateless bundled tools (+CALCULATOR+ / +WEB_SCRAPE+ /
10
- # +FETCH+, shared value constants), web_search is *host-configured*:
11
- # the paid providers (Brave / Exa) join the cascade only when the host
12
- # passes their API key, so the tool is built per-wiring — like
13
- # {Code::Bash} — rather than handed out as a single shared constant.
14
- # pikuri reads no key from the environment; the host supplies them (the
15
- # bundled +bin/+ examples load a JSON config file by convention). See
16
- # CLAUDE.md "Environment is not a secret store".
9
+ # Unlike the stateless bundled tools (shared value constants), web_search is
10
+ # *host-configured*: the paid providers (Brave/Exa) join the cascade only
11
+ # when the host passes their key, so it's built per-wiring (like
12
+ # {Code::Bash}), not a shared constant. pikuri reads no key from the
13
+ # environment — the host supplies them (CLAUDE.md "Environment is not a
14
+ # secret store").
15
+ #
16
+ # == Sharing
17
+ #
18
+ # +P_shared_locked+, and the interesting case: build one per agent or share
19
+ # one — it makes no difference to the throttling, because the pacing state
20
+ # is neither per agent nor per instance. Each provider's limiter is a
21
+ # class-level constant ({Search::DuckDuckGo::LIMITER} and friends), so all
22
+ # ten agents queue behind the same one. That is the only correct scope:
23
+ # what it protects is the egress IP and the API key's quota, and neither is
24
+ # per agent.
25
+ #
26
+ # What *is* per instance is trivial — the provider list and a
27
+ # last-logged-set memo, which can only duplicate an INFO line. The result
28
+ # cache ({Engines::CACHE}) is class-level as well, so one agent's search
29
+ # answers another's within the TTL.
30
+ #
31
+ # The consequence a host should plan for is latency, not corruption: see
32
+ # {Search::RateLimiter} for the serialization and the shared circuit
33
+ # breaker. Which is why this is the one bundled tool that opts into the
34
+ # per-call {Tool::ExecuteContext} (see {Pikuri::Tool}'s +== Cancellation+):
35
+ # an agent queued behind another agent's pacing wait can abandon it, even
36
+ # though the tool itself may be shared.
17
37
  module WebSearch
18
38
  # Description shown to the LLM, opencode-shape (summary + +Usage:+).
19
39
  #
@@ -25,20 +45,32 @@ module Pikuri
25
45
  - Use this to find candidate URLs, then call web_scrape on the most promising one(s) for full content. Snippets alone rarely answer a question.
26
46
  DESC
27
47
 
28
- # Build the +web_search+ tool. It calls {Search::Engines#search},
29
- # which cascades through DuckDuckGo (always) plus whichever keyed
30
- # providers were configured, in random order, falling back on
31
- # temporary-unavailability errors and rendering the winning
32
- # provider's rows into a stable Markdown shape.
48
+ # Build the +web_search+ tool. Calls {Search::Engines#search}, which
49
+ # cascades the selected providers in random order, falling back on
50
+ # unavailability and rendering the winner's rows to a stable Markdown
51
+ # shape.
52
+ #
53
+ # WebSearch.build # DuckDuckGo alone
54
+ # WebSearch.build(brave_key: 'BSA...') # DuckDuckGo + Brave
55
+ # WebSearch.build(engines: [:brave], brave_key: 'BSA...') # Brave alone
33
56
  #
34
- # @param brave_key [String, nil] Brave Search API key; when present
35
- # (non-blank) Brave joins the cascade. Get one at
36
- # https://api-dashboard.search.brave.com.
37
- # @param exa_key [String, nil] Exa API key; when present (non-blank)
38
- # Exa joins the cascade. Get one at https://exa.ai.
57
+ # @param engines [Array<String, Symbol>, nil] engine names to use —
58
+ # +duckduckgo+, +brave+, +exa+. +nil+ selects DuckDuckGo plus every
59
+ # engine whose key is supplied. Order carries no preference; see
60
+ # {Search::Engines.resolve}.
61
+ # @param brave_key [String, nil] Brave key
62
+ # (https://api-dashboard.search.brave.com).
63
+ # @param exa_key [String, nil] Exa key (https://exa.ai).
39
64
  # @return [Tool] the +web_search+ tool in OpenAI tool-call shape
40
- def self.build(brave_key: nil, exa_key: nil)
41
- engines = Search::Engines.new(brave_key: brave_key, exa_key: exa_key)
65
+ # @raise [ArgumentError] on an unusable selection: an empty or repeated
66
+ # +engines+ list, an unknown engine name, or an engine named without
67
+ # its key.
68
+ def self.build(engines: nil, brave_key: nil, exa_key: nil)
69
+ # Validated here as well as inside the cascade, so a mis-wired host is
70
+ # refused at the call it actually wrote even if this stops constructing
71
+ # the cascade eagerly.
72
+ Search::Engines.resolve(engines: engines, brave_key: brave_key, exa_key: exa_key)
73
+ cascade = Search::Engines.new(engines: engines, brave_key: brave_key, exa_key: exa_key)
42
74
  Tool.new(
43
75
  name: 'web_search',
44
76
  description: DESCRIPTION,
@@ -49,9 +81,53 @@ module Pikuri
49
81
  'Maximum number of result entries to return. ' \
50
82
  'Defaults to 10; most providers cap this at 20.'
51
83
  },
52
- execute: ->(query:, max_results: 10) { engines.search(query, max_results: max_results) }
84
+ # +wants_context:+ opts in to the per-call {Tool::ExecuteContext}, so
85
+ # a shared instance still honours whichever agent is waiting.
86
+ wants_context: true,
87
+ execute: lambda { |query:, max_results: 10, context:|
88
+ cascade.search(query, max_results: max_results, cancellable: context.cancellable)
89
+ },
90
+ # Snippets are written by whoever ranks for the query, and the query
91
+ # string itself leaves the machine — a search is an outbound channel
92
+ # even though nothing is "sent". Nobody reviews that query, hence
93
+ # +:unreviewed+; the whole difference from {FETCH} is the destination.
94
+ #
95
+ # +:tool_vouched+ against the three clauses: the caller supplies a
96
+ # query, never a host, and the engine set is this file's own
97
+ # ({Search::Engines::ENGINE_NAMES}); a search engine does not
98
+ # republish the queries it receives; and the request rides TLS to a
99
+ # party with no relationship to the attacker. A host picks from that
100
+ # closed list of three and cannot extend it, so the destination is
101
+ # still *this code* choosing, not the model.
102
+ #
103
+ # It is not a claim that the leg is harmless. A secret can still be
104
+ # pushed out inside a query — it just cannot be read back, and only
105
+ # while this stays the agent's sole egress leg.
106
+ trifecta_legs: Pikuri::Tool::TrifectaLegs.new(
107
+ private: false, untrusted: :hard,
108
+ egress_payload_review: :unreviewed, egress_destination: :tool_vouched
109
+ )
53
110
  )
54
111
  end
112
+
113
+ # Build the +web_search+ tool from a config file's +search:+ block, the
114
+ # host-facing shortcut for {build}:
115
+ #
116
+ # # ~/.pikuri-examples-config.yaml
117
+ # search:
118
+ # brave_key: BSA...
119
+ # engines: [brave]
120
+ #
121
+ # c.add_tool Pikuri::Tool::WebSearch.from_h(examples_config['search'])
122
+ #
123
+ # @param config [Hash{String, Symbol => Object}, nil] the +search:+ block;
124
+ # +nil+ (an absent key) yields the DuckDuckGo-only default.
125
+ # @return [Tool] the +web_search+ tool in OpenAI tool-call shape
126
+ # @raise [ArgumentError] as {build}, plus on a malformed block; see
127
+ # {Search::Engines.config_from_h}.
128
+ def self.from_h(config)
129
+ build(**Search::Engines.config_from_h(config))
130
+ end
55
131
  end
56
132
  end
57
133
  end
data/lib/pikuri/tool.rb CHANGED
@@ -5,45 +5,90 @@ require 'ruby_llm'
5
5
  module Pikuri
6
6
  # A tool the LLM can request via OpenAI-style tool calling.
7
7
  #
8
- # {Tool} is a plain value object: +name+, +description+, +parameters+,
9
- # and an +execute+ Proc that produces the observation. Pikuri's own
10
- # code talks to this surface (the bundled tools, the sub-agent factory,
11
- # the +Agent+ constructor); the conversion to ruby_llm's runtime shape
12
- # happens at exactly one named seam — {#to_ruby_llm_tool} — which the
13
- # +Agent+ calls when wiring tools into the underlying +RubyLLM::Chat+.
14
- #
15
- # Bundled tool implementations live under +lib/tool/+ and are required
16
- # explicitly by the scripts that use them; +lib/tool.rb+ itself only
17
- # introduces the {Tool} class and {Tool::Parameters} machinery.
8
+ # A plain value object — +name+, +description+, +parameters+, and an
9
+ # +execute+ Proc that produces the observation. Pikuri's code talks to this
10
+ # surface; conversion to ruby_llm's runtime shape happens at exactly one seam,
11
+ # {#to_ruby_llm_tool}, which the +Agent+ calls when wiring tools into the
12
+ # +RubyLLM::Chat+. Bundled implementations live under +lib/tool/+ and are
13
+ # required explicitly by the scripts that use them.
18
14
  #
19
15
  # == Validation ordering
20
16
  #
21
- # Pikuri's {Parameters#validate} — DidYouMean suggestions, type coercion,
22
- # all-errors-collected — is what the LLM must see when it emits bad
23
- # arguments, not ruby_llm's keyword-presence checker. The synthetic
24
- # class produced by {#to_ruby_llm_tool} defines +execute(**args)+: the
25
- # keyrest signature makes +RubyLLM::Tool#validate_keyword_arguments+
26
- # no-op (it bails out at the +accepts_extra_keywords+ check), so
27
- # pikuri's validator runs inside {#run} before any user code does.
17
+ # Pikuri's {Parameters#validate} (DidYouMean suggestions, type coercion,
18
+ # all-errors-collected) is what the LLM must see on bad arguments, not
19
+ # ruby_llm's keyword-presence checker. The synthetic class from
20
+ # {#to_ruby_llm_tool} defines +execute(**args)+: the keyrest signature makes
21
+ # +RubyLLM::Tool#validate_keyword_arguments+ no-op, so pikuri's validator runs
22
+ # inside {#run} before any user code.
28
23
  #
29
24
  # == Error handling convention
30
25
  #
31
- # Tools split failures into two buckets:
32
- #
33
- # * *Recoverable* failures — anything the LLM can react to by retrying with
34
- # different inputs (bad arguments, HTTP 4xx/5xx, network blips, search
35
- # provider rate-limits, division by zero in the calculator, ...). These
36
- # come back as a +"Error: <message>"+ String and become the next
37
- # observation; the model sees them and self-corrects on the following
38
- # turn instead of crashing the agent loop.
39
- # * *Bugs* in pikuri's own code (parser regressions, schema misuse,
40
- # misconfigured clients, ...). These keep raising. The LLM cannot fix
41
- # them; a human needs to.
42
- #
43
- # Argument-validation failures from {Parameters#validate} are caught by
44
- # {#run} and turned into +"Error: ..."+ observations. The +execute+ Proc
45
- # owns the rest — it should follow the same convention internally for
46
- # tool-specific recoverable failures, and let bugs raise.
26
+ # * *Recoverable* failures (bad arguments, HTTP 4xx/5xx, network blips,
27
+ # rate-limits, division by zero, ...) come back as +"Error: <message>"+
28
+ # Strings and become the next observation, so the model self-corrects.
29
+ # * *Bugs* in pikuri's own code (parser regressions, schema misuse, ...) keep
30
+ # raising — the LLM can't fix them.
31
+ #
32
+ # {#run} catches {Parameters#validate} failures into +"Error: ..."+; the
33
+ # +execute+ Proc follows the same convention for its own recoverable failures
34
+ # and lets bugs raise.
35
+ #
36
+ # == Sharing: may one instance serve two agents?
37
+ #
38
+ # {Pikuri::Agent} is thread-unsafe, so a host running ten agents in one VM
39
+ # serializes each agent's own calls — usually a thread apiece. That makes
40
+ # "may a second agent have this tool?" and "is this reachable from two
41
+ # threads?" one question. Every tool answers it, as a +Sharing:+ tail or a
42
+ # +== Sharing+ section, naming one posture:
43
+ #
44
+ # * *P_stateless* — no mutable state. Share freely.
45
+ # * *P_shared_locked* — process-wide mutable state under a lock. Sharing is
46
+ # intended, and for anything pacing a shared *external* resource it is
47
+ # mandatory: a per-agent {Search::RateLimiter} would let ten agents fire
48
+ # ten simultaneous requests from one IP and earn the ban it exists to
49
+ # avoid.
50
+ # * *P_shared_benign* — unprotected process-wide state whose race is
51
+ # harmless; the doc names the race ({Pikuri::UrlCache}: both callers
52
+ # compute a cold key and write identical bytes).
53
+ # * *P_one_agent* — agent-scoped state, so exactly one agent. It needs no
54
+ # lock because its only caller is that agent, whose calls are already
55
+ # serialized; wire it twice and you lose that for free. And the first
56
+ # failure isn't a race but a correctness bug — agent A would read agent B's
57
+ # facts single-threaded too.
58
+ #
59
+ # +P_one_agent+ is a fine answer, not a defect: most of what a tool knows is
60
+ # one conversation's business. Nothing enforces it, so the doc *is* the
61
+ # contract. The two axes stay independent — a {Skill::Activation} locks *and*
62
+ # is per-agent, so "thread-safe" is no licence to share.
63
+ #
64
+ # A +Tool+ is a value object holding a +Proc+, so its posture is its
65
+ # collaborators': each tool names the one that owns the state, which is also
66
+ # where any lock lives.
67
+ #
68
+ # The one way to break the premise from outside pikuri: ruby_llm's
69
+ # +tool_concurrency+ config (default +false+) makes +Chat+ run a tool batch on
70
+ # threads, so a *single* agent would enter its own +P_one_agent+ tools
71
+ # concurrently. Leave it off.
72
+ #
73
+ # == Cancellation
74
+ #
75
+ # A tool that *waits* can opt into being interrupted: pass
76
+ # +wants_context: true+ and take a +context:+ keyword, then wait through
77
+ # {Pikuri::Agent::Control::Cancellable#sleep} on the {ExecuteContext} handed
78
+ # in.
79
+ #
80
+ # wants_context: true,
81
+ # execute: ->(query:, context:) { backend.search(query, cancellable: context.cancellable) }
82
+ #
83
+ # Opt-in rather than universal because most tools have nothing to cancel, and
84
+ # because a token only reaches *waits* — a tool blocked in IO (a Faraday read,
85
+ # +Subprocess#wait+) stays uninterruptible however many tokens it holds.
86
+ #
87
+ # Per *call*, not per construction, and that is the point: {#to_ruby_llm_tool}
88
+ # mints a fresh wrapper per agent, so even a +P_shared_locked+ tool shared by
89
+ # ten agents honours whichever one is actually waiting. The context is always
90
+ # a real object ({ExecuteContext.default}, whose cancellation never trips,
91
+ # when the host wired none), so an +execute+ never nil-checks it.
47
92
  class Tool
48
93
  # @return [String] function name advertised to the LLM
49
94
  attr_reader :name
@@ -57,60 +102,90 @@ module Pikuri
57
102
  # LLM
58
103
  attr_reader :parameters
59
104
 
60
- # @return [Proc] callable invoked once arguments have been validated;
61
- # receives validated keyword arguments and returns the observation.
62
- # The usual return is a +String+; tools that want to deliver
63
- # multimodal observations (e.g. {Pikuri::Workspace::Read} on a
64
- # PNG) may instead return a +RubyLLM::Content+ with attachments,
65
- # which +RubyLLM::Chat#handle_tool_calls+ accepts via its
66
- # +content_like?+ check and the per-provider Media formatters turn
67
- # into the right image / document blocks inside +tool_result+.
105
+ # @return [Proc] callable invoked with validated keyword arguments,
106
+ # returning the observation — usually a +String+, or a +RubyLLM::Content+
107
+ # with attachments for multimodal observations (e.g.
108
+ # {Pikuri::Workspace::Read} on a PNG), which +RubyLLM::Chat+ turns into
109
+ # the right image/document blocks.
68
110
  attr_reader :execute
69
111
 
112
+ # @return [Tool::TrifectaLegs] which lethal-trifecta legs this tool
113
+ # contributes, for {Pikuri::Trifecta}
114
+ attr_reader :trifecta_legs
115
+
70
116
  # @param name [String] function name advertised to the LLM
71
117
  # @param description [String] human-readable description used by the LLM
72
118
  # to decide when to call the tool
73
119
  # @param parameters [Tool::Parameters] declared schema
74
- # @param execute [Proc] callable invoked with validated keyword arguments
75
- # that returns either a +String+ observation or a +RubyLLM::Content+
76
- # (for multimodal observations — see {#execute}). Recoverable failures
77
- # should be returned as +"Error: <message>"+ Strings rather than raised
78
- # — see "Error handling convention" above.
120
+ # @param execute [Proc] callable invoked with validated keyword arguments,
121
+ # returning a +String+ or +RubyLLM::Content+ (see {#execute}). Recoverable
122
+ # failures return +"Error: <message>"+ rather than raise.
123
+ # @param trifecta_legs [Tool::TrifectaLegs] legs this tool contributes.
124
+ # Compute them here rather than at a later resolution pass: construction
125
+ # *is* wiring time, since every collaborator that decides a leg (the
126
+ # workspace, the sandbox, the confirmer) is already a kwarg of the tool
127
+ # that uses it. Defaults to {TrifectaLegs::ASSUMED} — an undeclared tool is
128
+ # presumed hostile on both axes the framework could have determined.
129
+ # @param wants_context [Boolean] when +true+, {#run} additionally passes a
130
+ # +context:+ keyword carrying this call's {ExecuteContext} — so +execute+
131
+ # must declare one. Declared rather than inferred from the Proc, and both
132
+ # halves are mandatory, so either without the other is an +ArgumentError+
133
+ # on the first call rather than a silent +nil+.
79
134
  # @return [Tool]
80
- def initialize(name:, description:, parameters:, execute:)
135
+ def initialize(name:, description:, parameters:, execute:,
136
+ trifecta_legs: TrifectaLegs::ASSUMED, wants_context: false)
81
137
  @name = name
82
138
  @description = description
83
139
  @parameters = parameters
84
140
  @execute = execute
141
+ @trifecta_legs = trifecta_legs
142
+ @wants_context = wants_context
85
143
  end
86
144
 
87
- # Validate +args+ against {#parameters} and forward them as keyword
88
- # arguments to {#execute}. Validation failures are caught and rendered
89
- # as +"Error: <message>"+ Strings so the agent loop can feed them back
90
- # to the LLM as the next observation; everything else bubbles up.
145
+ # @return [Boolean] whether {#run} hands +execute+ a +context:+ keyword
146
+ # carrying the {ExecuteContext} for this call. Declared at construction,
147
+ # never inferred from the Proc's signature — see the +== Cancellation+
148
+ # section.
149
+ attr_reader :wants_context
150
+
151
+ # Validate +args+ against {#parameters} and forward them as kwargs to
152
+ # {#execute}. Validation failures come back as +"Error: <message>"+ Strings
153
+ # for the next observation; everything else bubbles up.
91
154
  #
92
155
  # @param args [Hash] raw arguments supplied by the LLM
93
- # @return [String, RubyLLM::Content] tool observation. Validation
94
- # failures always come back as +"Error: ..."+ Strings; the success
95
- # shape is whatever the +execute+ Proc returns (typically a String,
96
- # or a +RubyLLM::Content+ for multimodal observations).
97
- def run(args)
156
+ # @param context [ExecuteContext] this invocation's environment, forwarded
157
+ # as a +context:+ keyword only when {#wants_context}. Never part of
158
+ # +args+ — the LLM cannot supply it, since {Parameters#validate} refuses
159
+ # unknown keys. Positional against the house style on purpose: give this
160
+ # method *any* keyword and Ruby 3 re-reads the bare hash in
161
+ # +run('text' => 'hi')+ as keywords, so +args+ arrives empty at every
162
+ # existing call site.
163
+ # @return [String, RubyLLM::Content] tool observation — validation failures
164
+ # always +"Error: ..."+; success is whatever +execute+ returns.
165
+ def run(args, context = ExecuteContext.default)
98
166
  validated = @parameters.validate(args)
99
- @execute.call(**validated)
167
+ if @wants_context
168
+ @execute.call(**validated, context: context)
169
+ else
170
+ @execute.call(**validated)
171
+ end
100
172
  rescue Tool::Parameters::ValidationError => e
101
173
  "Error: #{e.message}"
102
174
  end
103
175
 
104
- # Build a synthetic +RubyLLM::Tool+ subclass that wraps this Tool. The
105
- # subclass is what +RubyLLM::Chat#with_tool+ accepts: ruby_llm
106
- # instantiates it (+tool.new+) and routes tool calls through
107
- # +instance.call(args)+, which lands in +#execute(**args)+ and
108
- # delegates back to {#run} on this instance.
176
+ # Build a synthetic +RubyLLM::Tool+ subclass wrapping this Tool — what
177
+ # +RubyLLM::Chat#with_tool+ accepts. ruby_llm instantiates it and routes
178
+ # tool calls through +#execute(**args)+, which delegates to {#run}.
179
+ #
180
+ # A wrapper is per *agent* even when this +Tool+ is shared between several,
181
+ # which is what lets it close over one agent's cancellation token.
109
182
  #
183
+ # @param context [ExecuteContext] passed to {#run} on every call; see the
184
+ # +== Cancellation+ section.
110
185
  # @return [Class] anonymous +RubyLLM::Tool+ subclass
111
- def to_ruby_llm_tool
186
+ def to_ruby_llm_tool(context: ExecuteContext.default)
112
187
  pikuri_tool = self
113
- schema = @parameters.to_h
188
+ schema = @parameters.to_openai
114
189
  tool_name = @name
115
190
  tool_desc = @description
116
191
 
@@ -119,7 +194,7 @@ module Pikuri
119
194
  params(schema)
120
195
 
121
196
  define_singleton_method(:name) { tool_name }
122
- define_method(:execute) { |**args| pikuri_tool.run(args) }
197
+ define_method(:execute) { |**args| pikuri_tool.run(args, context) }
123
198
  end
124
199
  end
125
200
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Trifecta
5
+ # What an extension adds to the tree, returned from
6
+ # {Pikuri::Agent::Extension#trifecta_contribution}. Two independent things,
7
+ # either of which may be empty:
8
+ #
9
+ # # a sub-agent extension: one child per persona, nothing of its own
10
+ # Contribution.new(legs: Pikuri::Tool::TrifectaLegs::NONE, children: nodes)
11
+ #
12
+ # # an MCP extension: legs no tool can declare, no children
13
+ # Contribution.new(label: '(mcp)', legs: assumed_with_private, children: [])
14
+ #
15
+ # Needed because two things in the tree are invisible to per-tool tagging:
16
+ # a sub-agent is a whole node the tools cannot describe, and a foreign tool
17
+ # surface (MCP) arrives as raw +RubyLLM::Tool+ subclasses with no leg
18
+ # surface at all.
19
+ #
20
+ # Immutable.
21
+ #
22
+ # @!attribute [r] label
23
+ # @return [String] name of the row these +legs+ appear under in the
24
+ # report, parenthesized to mark it as not-a-tool. It is what
25
+ # {Pikuri::Trifecta::Report#attribution} points the reader at, so it
26
+ # has to name the surface they can actually go and change — two
27
+ # extensions declaring under one label would blame each other's
28
+ # wiring.
29
+ # @!attribute [r] legs
30
+ # @return [Pikuri::Tool::TrifectaLegs] legs this extension adds to the
31
+ # node it is wired into, over and above that node's own tools
32
+ # @!attribute [r] children
33
+ # @return [Array<Node>] child nodes this extension introduces
34
+ class Contribution < Data.define(:label, :legs, :children)
35
+ # @param label [String] row name for +legs+; ignored when they are +NONE+
36
+ # @param legs [Pikuri::Tool::TrifectaLegs]
37
+ # @param children [Array<Node>]
38
+ def initialize(label: '(extension)', legs: Pikuri::Tool::TrifectaLegs::NONE, children: [])
39
+ super
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Trifecta
5
+ # One agent in the tree the detector walks: the main agent at the root, a
6
+ # persona per sub-agent below it. Assembled from a wired +Agent+ (its tools
7
+ # plus every extension's
8
+ # {Pikuri::Agent::Extension#trifecta_contribution}), and hand-built in
9
+ # specs — which is the point of carrying plain data rather than live
10
+ # objects:
11
+ #
12
+ # Node.new(label: 'demo',
13
+ # tool_legs: { 'read' => read_legs },
14
+ # children: [Node.new(label: 'RESEARCHER',
15
+ # tool_legs: { 'fetch' => fetch_legs },
16
+ # channel_egress: :human_reviewed)])
17
+ #
18
+ # Immutable.
19
+ #
20
+ # @!attribute [r] label
21
+ # @return [String] name shown in the report — the binary/agent name at the
22
+ # root, the persona name for a sub-agent
23
+ # @!attribute [r] tool_legs
24
+ # @return [Hash{String=>Pikuri::Tool::TrifectaLegs}] this agent's own
25
+ # tools, in wiring order. The per-tool breakdown is kept rather than
26
+ # pre-unioned so the rendered tree can show *which* tool contributes a
27
+ # leg instead of a bare total.
28
+ # @!attribute [r] children
29
+ # @return [Array<Node>] sub-agents this agent can dispatch to
30
+ # @!attribute [r] channel_egress
31
+ # @return [Symbol, nil] how much egress the delegation channel *from the
32
+ # parent* permits, on the {Pikuri::Tool::TrifectaLegs} review axis —
33
+ # +:human_reviewed+ when a human gates the hand-off, +:unreviewed+
34
+ # otherwise. A property of the edge, stored on the child
35
+ # because every child has exactly one parent edge; +nil+ at the root,
36
+ # which has none.
37
+ class Node < Data.define(:label, :tool_legs, :children, :channel_egress)
38
+ # @param label [String]
39
+ # @param tool_legs [Hash{String=>Pikuri::Tool::TrifectaLegs}]
40
+ # @param children [Array<Node>]
41
+ # @param channel_egress [Symbol, nil]
42
+ def initialize(label:, tool_legs: {}, children: [], channel_egress: nil)
43
+ super
44
+ end
45
+ end
46
+ end
47
+ end