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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- 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.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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 (
|
|
10
|
-
#
|
|
11
|
-
# the
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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.
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# the +Agent+
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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}
|
|
22
|
-
# all-errors-collected
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# tool
|
|
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
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
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
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
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
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
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
|
-
# @
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
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
|
-
@
|
|
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
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
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.
|
|
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
|