pikuri-core 0.0.3
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 +7 -0
- data/README.md +67 -0
- data/lib/pikuri/agent/chat_transport.rb +41 -0
- data/lib/pikuri/agent/configurator.rb +270 -0
- data/lib/pikuri/agent/context_window_detector.rb +111 -0
- data/lib/pikuri/agent/control/cancellable.rb +128 -0
- data/lib/pikuri/agent/control/interloper.rb +167 -0
- data/lib/pikuri/agent/control/step_limit.rb +93 -0
- data/lib/pikuri/agent/control.rb +45 -0
- data/lib/pikuri/agent/event.rb +190 -0
- data/lib/pikuri/agent/extension.rb +82 -0
- data/lib/pikuri/agent/listener/in_memory_event_list.rb +34 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +172 -0
- data/lib/pikuri/agent/listener/terminal.rb +264 -0
- data/lib/pikuri/agent/listener/token_log.rb +216 -0
- data/lib/pikuri/agent/listener.rb +54 -0
- data/lib/pikuri/agent/listener_list.rb +102 -0
- data/lib/pikuri/agent/synthesizer.rb +145 -0
- data/lib/pikuri/agent.rb +731 -0
- data/lib/pikuri/subprocess.rb +166 -0
- data/lib/pikuri/tool/calculator.rb +82 -0
- data/lib/pikuri/tool/fetch.rb +171 -0
- data/lib/pikuri/tool/parameters.rb +314 -0
- data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
- data/lib/pikuri/tool/scraper/html.rb +285 -0
- data/lib/pikuri/tool/scraper/pdf.rb +54 -0
- data/lib/pikuri/tool/scraper/simple.rb +183 -0
- data/lib/pikuri/tool/search/brave.rb +184 -0
- data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
- data/lib/pikuri/tool/search/engines.rb +163 -0
- data/lib/pikuri/tool/search/exa.rb +217 -0
- data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
- data/lib/pikuri/tool/search/result.rb +29 -0
- data/lib/pikuri/tool/sub_agent.rb +150 -0
- data/lib/pikuri/tool/web_scrape.rb +121 -0
- data/lib/pikuri/tool/web_search.rb +38 -0
- data/lib/pikuri/tool.rb +118 -0
- data/lib/pikuri/url_cache.rb +112 -0
- data/lib/pikuri/version.rb +10 -0
- data/lib/pikuri-core.rb +177 -0
- data/prompts/pikuri-chat.txt +15 -0
- metadata +251 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module Pikuri
|
|
7
|
+
# Chokepoint for *all* subprocess spawning in pikuri. Forces a new
|
|
8
|
+
# process group for each invocation, tracks pgids so descendants of
|
|
9
|
+
# the direct child (commands backgrounded with +&+) can be cleaned
|
|
10
|
+
# up at process exit, and captures combined stdout+stderr through a
|
|
11
|
+
# single pipe.
|
|
12
|
+
#
|
|
13
|
+
# == Seam discipline
|
|
14
|
+
#
|
|
15
|
+
# All subprocess spawning in +lib/+ goes through {.spawn}. Direct
|
|
16
|
+
# +Process.spawn+ / +Open3.*+ / +system+ / backticks anywhere in
|
|
17
|
+
# +lib/+ are bugs. The convention is grep-enforceable:
|
|
18
|
+
# +grep -rn 'Process\.spawn\|Open3\|system\|backtick' lib/+ should
|
|
19
|
+
# only hit this file.
|
|
20
|
+
#
|
|
21
|
+
# == Timeouts are the caller's job
|
|
22
|
+
#
|
|
23
|
+
# {.spawn} does not implement a timeout — Ruby's +Timeout.timeout+
|
|
24
|
+
# cannot kill subprocesses cleanly. Callers that need a timeout
|
|
25
|
+
# wrap their argv with coreutils' +timeout+ binary:
|
|
26
|
+
#
|
|
27
|
+
# Pikuri::Subprocess.spawn(
|
|
28
|
+
# 'timeout', '--signal=TERM', '--kill-after=5s', '120s',
|
|
29
|
+
# 'bash', '-c', command,
|
|
30
|
+
# chdir: workspace.cwd.to_s
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# When +timeout+ and its FD-inheriting children die, the combined
|
|
34
|
+
# output pipe closes and {#wait}'s +io.read+ returns. No Ruby-side
|
|
35
|
+
# timeout machinery; the +timeout+ binary handles SIGTERM-then-
|
|
36
|
+
# SIGKILL race-free.
|
|
37
|
+
#
|
|
38
|
+
# == Backgrounded subprocesses
|
|
39
|
+
#
|
|
40
|
+
# When a shell command backgrounds work with +&+, the resulting
|
|
41
|
+
# process stays in our pgroup. {#wait} returns as soon as the
|
|
42
|
+
# direct child exits, but {.active} keeps the pgid in the tracked
|
|
43
|
+
# set as long as any process in the group is alive (probed with
|
|
44
|
+
# +kill(0, -pgid)+). On pikuri exit, {.cleanup!} sends SIGTERM to
|
|
45
|
+
# every tracked group. The model can opt out via +nohup cmd &+ or
|
|
46
|
+
# +setsid cmd &+ — both detach from our group.
|
|
47
|
+
#
|
|
48
|
+
# == State is process-global
|
|
49
|
+
#
|
|
50
|
+
# One +@active+ Set and one +at_exit+ for the whole process. A
|
|
51
|
+
# +Mutex+ guards register/prune/cleanup; v1 is single-threaded, so
|
|
52
|
+
# this is more for the +at_exit+/register race than for current
|
|
53
|
+
# callers.
|
|
54
|
+
#
|
|
55
|
+
# == Why +Pikuri::Subprocess+, not top-level
|
|
56
|
+
#
|
|
57
|
+
# First class actually under the +Pikuri::+ namespace. Domain
|
|
58
|
+
# classes (+Tool+, +Agent+, +URLCache+) are top-level as a legacy
|
|
59
|
+
# convention — they predate the namespacing decision and an
|
|
60
|
+
# eventual refactor moves them too. For now: library-level
|
|
61
|
+
# infrastructure under +Pikuri::+; domain objects flat. See
|
|
62
|
+
# +CLAUDE.md+ for the convention.
|
|
63
|
+
class Subprocess
|
|
64
|
+
# Combined output + exit status, returned from {#wait}.
|
|
65
|
+
Result = Data.define(:output, :status)
|
|
66
|
+
|
|
67
|
+
# Spawn +argv+ in a new process group, redirecting stderr onto
|
|
68
|
+
# stdout. Tracked for cleanup.
|
|
69
|
+
#
|
|
70
|
+
# @param argv [Array<String>] command and arguments. Caller does
|
|
71
|
+
# any shell wrapping (e.g. +'bash', '-c', cmd+) when shell
|
|
72
|
+
# interpretation is wanted; +argv+ is passed to +exec+
|
|
73
|
+
# directly, so no implicit shell expansion happens here.
|
|
74
|
+
# @param chdir [String, Pathname] working directory
|
|
75
|
+
# @return [Subprocess] handle — call {#wait} to block for the
|
|
76
|
+
# direct child to exit and read the captured output
|
|
77
|
+
def self.spawn(*argv, chdir:)
|
|
78
|
+
stdin, io, wait_thr = Open3.popen2e(*argv, chdir: chdir.to_s, pgroup: true)
|
|
79
|
+
stdin.close
|
|
80
|
+
register(wait_thr.pid)
|
|
81
|
+
new(io: io, wait_thr: wait_thr)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Integer] direct child's pid
|
|
85
|
+
attr_reader :pid
|
|
86
|
+
|
|
87
|
+
# @return [Integer] process group id. Equal to {#pid} since the
|
|
88
|
+
# child was spawned with +pgroup: true+ (it's the group leader).
|
|
89
|
+
attr_reader :pgid
|
|
90
|
+
|
|
91
|
+
# @return [IO] read end of the combined stdout+stderr pipe.
|
|
92
|
+
# Exposed for future live-streaming consumers; v1 callers go
|
|
93
|
+
# straight to {#wait}, which drains it.
|
|
94
|
+
attr_reader :io
|
|
95
|
+
|
|
96
|
+
# @api private — call {.spawn}, not the constructor.
|
|
97
|
+
def initialize(io:, wait_thr:)
|
|
98
|
+
@io = io
|
|
99
|
+
@wait_thr = wait_thr
|
|
100
|
+
@pid = wait_thr.pid
|
|
101
|
+
@pgid = wait_thr.pid # pgroup:true → pgid == pid
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Block until the direct child exits, read whatever remains on
|
|
105
|
+
# the combined-output pipe, return a {Result}. The pgid stays
|
|
106
|
+
# tracked if the group still has live members (backgrounded
|
|
107
|
+
# children); pruned if everything's gone.
|
|
108
|
+
#
|
|
109
|
+
# @return [Result]
|
|
110
|
+
def wait
|
|
111
|
+
output = @io.read
|
|
112
|
+
@io.close
|
|
113
|
+
Result.new(output: output, status: @wait_thr.value)
|
|
114
|
+
ensure
|
|
115
|
+
self.class.send(:prune, @pgid)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
# Currently-tracked process groups, with dead ones pruned as a
|
|
120
|
+
# side effect. Useful for a future +/bg+ REPL command or a
|
|
121
|
+
# between-turn status line.
|
|
122
|
+
#
|
|
123
|
+
# @return [Array<Integer>]
|
|
124
|
+
def active
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
@active.delete_if { |g| !alive?(g) }
|
|
127
|
+
@active.to_a
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# SIGTERM every tracked process group. Used by +at_exit+
|
|
132
|
+
# (production) and +after+ blocks (specs). Best-effort —
|
|
133
|
+
# ignores errors from already-dead groups.
|
|
134
|
+
#
|
|
135
|
+
# @return [void]
|
|
136
|
+
def cleanup!
|
|
137
|
+
@mutex.synchronize do
|
|
138
|
+
@active.each { |g| Process.kill('-TERM', g) rescue nil }
|
|
139
|
+
@active.clear
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def register(pgid)
|
|
146
|
+
@mutex.synchronize { @active << pgid }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def prune(pgid)
|
|
150
|
+
@mutex.synchronize { @active.delete(pgid) unless alive?(pgid) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def alive?(pgid)
|
|
154
|
+
Process.kill(0, -pgid)
|
|
155
|
+
true
|
|
156
|
+
rescue Errno::ESRCH
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@active = Set.new
|
|
162
|
+
@mutex = Mutex.new
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
at_exit { Pikuri::Subprocess.cleanup! }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dentaku'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
class Tool
|
|
7
|
+
# Evaluates a basic arithmetic expression using Dentaku, with light
|
|
8
|
+
# preprocessing so the LLM can emit Python-flavored syntax (notably
|
|
9
|
+
# +**+ for exponentiation) instead of learning Dentaku's dialect.
|
|
10
|
+
#
|
|
11
|
+
# Scope is intentionally narrow: operators (+, -, *, /, **, %),
|
|
12
|
+
# parentheses, and decimal numbers. No variables, functions, or
|
|
13
|
+
# booleans — those would mean teaching the model a dialect, which we
|
|
14
|
+
# specifically want to avoid for this tool.
|
|
15
|
+
module Calculator
|
|
16
|
+
# Translate the operator differences between Python and Dentaku. In
|
|
17
|
+
# practice that is only +**+ → +^+; everything else in the supported
|
|
18
|
+
# subset is byte-identical.
|
|
19
|
+
#
|
|
20
|
+
# @param expression [String] raw expression as the model wrote it
|
|
21
|
+
# @return [String] expression with Python-style operators rewritten
|
|
22
|
+
def self.normalize(expression)
|
|
23
|
+
expression.gsub('**', '^')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Evaluate +expression+ and return the result formatted as a String.
|
|
27
|
+
# Parse, unbound-variable, and division-by-zero failures are caught
|
|
28
|
+
# and returned as +"Error: ..."+ strings so the model can read the
|
|
29
|
+
# failure as the next observation and self-correct rather than
|
|
30
|
+
# crashing the agent loop.
|
|
31
|
+
#
|
|
32
|
+
# @param expression [String]
|
|
33
|
+
# @return [String] numeric result, or +"Error: ..."+ on failure
|
|
34
|
+
def self.calculate(expression)
|
|
35
|
+
result = Dentaku::Calculator.new.evaluate!(normalize(expression))
|
|
36
|
+
format_result(result)
|
|
37
|
+
rescue Dentaku::ZeroDivisionError, ZeroDivisionError
|
|
38
|
+
'Error: division by zero'
|
|
39
|
+
rescue Dentaku::Error => e
|
|
40
|
+
"Error: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Dentaku returns BigDecimal for any expression that touches division
|
|
44
|
+
# or a decimal literal, with full BigDecimal precision (47-digit tails
|
|
45
|
+
# for the leopard expression). Round to 3 places and strip the
|
|
46
|
+
# default scientific-notation formatting so the model sees a short
|
|
47
|
+
# readable number; integer/other results pass through unchanged.
|
|
48
|
+
def self.format_result(result)
|
|
49
|
+
case result
|
|
50
|
+
when BigDecimal then result.round(3).to_s('F')
|
|
51
|
+
else result.to_s
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
private_class_method :format_result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Arithmetic-evaluation tool backed by {Tool::Calculator.calculate}.
|
|
58
|
+
# Accepts Python-flavored operator syntax (+, -, *, /, ** for
|
|
59
|
+
# exponentiation, %, parentheses, decimals) so the model can emit the
|
|
60
|
+
# syntax it already knows.
|
|
61
|
+
#
|
|
62
|
+
# @return [Tool]
|
|
63
|
+
CALCULATOR = new(
|
|
64
|
+
name: 'calculator',
|
|
65
|
+
description: <<~DESC,
|
|
66
|
+
Evaluates a basic arithmetic expression and returns the numeric result.
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
- Use this for any arithmetic beyond simple mental math — do not eyeball multi-digit work.
|
|
70
|
+
- Operators supported: +, -, *, /, ** (exponentiation), %, parentheses, decimal numbers.
|
|
71
|
+
- Decimal results are rounded to 3 places; integer results are exact.
|
|
72
|
+
- Failures (parse error, division by zero) come back as "Error: ..." — read the message and re-call with a corrected expression.
|
|
73
|
+
DESC
|
|
74
|
+
parameters: Parameters.build { |p|
|
|
75
|
+
p.required_string :expression,
|
|
76
|
+
'Arithmetic expression to evaluate, e.g. ' \
|
|
77
|
+
'"155 / (58 * 1000.0 / 3600)" or "2**10".'
|
|
78
|
+
},
|
|
79
|
+
execute: ->(expression:) { Calculator.calculate(expression) }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Tool
|
|
5
|
+
# Truncation policy and Tool spec for the +fetch+ tool. The HTTP work
|
|
6
|
+
# lives in {Tool::Scraper::Simple.fetch}; this module is a thin
|
|
7
|
+
# wrapper that accepts only textual content-types, applies a character
|
|
8
|
+
# cap so the LLM doesn't drown in long-form bodies, and exposes the
|
|
9
|
+
# result to the agent loop in OpenAI tool-call shape.
|
|
10
|
+
#
|
|
11
|
+
# Sister of {Tool::WebScrape}, but without HTML→Markdown or PDF→text
|
|
12
|
+
# extraction: bodies are returned verbatim. Useful for raw textual
|
|
13
|
+
# data — JSON APIs, CSV files, +robots.txt+, sitemaps, source files —
|
|
14
|
+
# where any rendering pass would corrupt the payload.
|
|
15
|
+
module Fetch
|
|
16
|
+
# @return [Integer] default character cap on the body returned by
|
|
17
|
+
# {.fetch}. Smaller than {Tool::WebScrape::DEFAULT_MAX_CHARS}
|
|
18
|
+
# because fetch's content profile is bimodal — most JSON/XML/CSV
|
|
19
|
+
# responses are tiny, and the long-tail (large data dumps) is
|
|
20
|
+
# better re-requested deliberately than padded into every default.
|
|
21
|
+
DEFAULT_MAX_CHARS = 5_000
|
|
22
|
+
|
|
23
|
+
# @return [Integer] hard ceiling on the +max_chars+ argument to
|
|
24
|
+
# {.fetch}. Matches {Tool::WebScrape::MAX_MAX_CHARS}.
|
|
25
|
+
MAX_MAX_CHARS = 100_000
|
|
26
|
+
|
|
27
|
+
# Application content-types that are textual in practice and so
|
|
28
|
+
# safe to return verbatim to the LLM, despite their +application/+
|
|
29
|
+
# prefix making them fail the +text/*+ check. Anything outside
|
|
30
|
+
# +text/*+ and this allowlist is refused.
|
|
31
|
+
# @return [Array<String>]
|
|
32
|
+
TEXTUAL_APPLICATION_TYPES = %w[
|
|
33
|
+
application/json
|
|
34
|
+
application/xml
|
|
35
|
+
application/javascript
|
|
36
|
+
application/xhtml+xml
|
|
37
|
+
application/rss+xml
|
|
38
|
+
application/atom+xml
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# On-disk cache used by {.fetch} to memoize downloads. Defined as a
|
|
42
|
+
# method so specs can swap it for an isolated cache or
|
|
43
|
+
# {UrlCache::NULL} without touching the shared instance. Lives in
|
|
44
|
+
# its own subdir under {UrlCache::ROOT_DIR} so a +fetch+ on a URL
|
|
45
|
+
# and a +web_scrape+ on the same URL cannot collide on the same
|
|
46
|
+
# cache file (one returns the raw body, the other returns extracted
|
|
47
|
+
# Markdown).
|
|
48
|
+
#
|
|
49
|
+
# @return [UrlCache, #fetch]
|
|
50
|
+
CACHE = UrlCache.new(ttl: UrlCache::DEFAULT_TTL, dir: "#{UrlCache::ROOT_DIR}/fetch")
|
|
51
|
+
# Accessor for {CACHE}; specs override this to swap in
|
|
52
|
+
# {UrlCache::NULL} or an isolated cache.
|
|
53
|
+
#
|
|
54
|
+
# @return [UrlCache, #fetch]
|
|
55
|
+
def self.cache
|
|
56
|
+
CACHE
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Download +url+ via {Tool::Scraper::Simple.fetch} and return the
|
|
60
|
+
# response body verbatim, provided the content-type is one we deem
|
|
61
|
+
# textual (any +text/*+, plus the formats listed in
|
|
62
|
+
# {TEXTUAL_APPLICATION_TYPES}). Anything else — PDFs, images, other
|
|
63
|
+
# binaries — produces an +"Error: ..."+ string in the calculator-
|
|
64
|
+
# style convention so the agent loop feeds the failure back to the
|
|
65
|
+
# model as the next observation.
|
|
66
|
+
#
|
|
67
|
+
# The body is cached on disk via {.cache}, keyed by URL, so repeat
|
|
68
|
+
# fetches within the cache TTL skip the network. +max_chars+ is not
|
|
69
|
+
# part of the cache key — different values for the same URL share
|
|
70
|
+
# one entry, and truncation runs after the cache lookup. The cache
|
|
71
|
+
# is only populated on success: {Scraper::FetchError} (HTTP non-2xx,
|
|
72
|
+
# network failure, redirect-loop exhaustion, refused content-type)
|
|
73
|
+
# is caught outside the +cache.fetch+ block, so failure strings are
|
|
74
|
+
# never persisted and a retry on the next call hits the network
|
|
75
|
+
# again. Other exceptions (parser bugs in our own code) bubble up
|
|
76
|
+
# unchanged.
|
|
77
|
+
#
|
|
78
|
+
# @param url [String] absolute HTTP(S) URL to download
|
|
79
|
+
# @param max_chars [Integer] character cap on the returned body.
|
|
80
|
+
# Clamped to +[1, {MAX_MAX_CHARS}]+; defaults to
|
|
81
|
+
# {DEFAULT_MAX_CHARS}. When the body exceeds the cap, output is
|
|
82
|
+
# cut and a marker noting the original length is appended.
|
|
83
|
+
# @return [String] response body, possibly truncated, or
|
|
84
|
+
# +"Error: ..."+ on a recoverable failure
|
|
85
|
+
def self.fetch(url, max_chars: DEFAULT_MAX_CHARS)
|
|
86
|
+
max_chars = max_chars.clamp(1, MAX_MAX_CHARS)
|
|
87
|
+
body = cache.fetch(url) { download(url) }
|
|
88
|
+
truncate(body, max_chars)
|
|
89
|
+
rescue Scraper::FetchError => e
|
|
90
|
+
"Error: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# GET +url+ and verify the response's content-type is textual.
|
|
94
|
+
# Caller is responsible for caching and truncation; this method
|
|
95
|
+
# always hits the network.
|
|
96
|
+
#
|
|
97
|
+
# @param url [String]
|
|
98
|
+
# @return [String] response body
|
|
99
|
+
# @raise [Scraper::FetchError] on HTTP non-2xx, network failure,
|
|
100
|
+
# redirect-loop exhaustion, missing +Location+ on a 3xx, or a
|
|
101
|
+
# non-textual content-type
|
|
102
|
+
def self.download(url)
|
|
103
|
+
fetched = Scraper::Simple.fetch(url)
|
|
104
|
+
return fetched.body if textual?(fetched.content_type)
|
|
105
|
+
|
|
106
|
+
raise Scraper::FetchError,
|
|
107
|
+
"refused to fetch #{url}: content-type #{fetched.content_type.inspect} " \
|
|
108
|
+
'is not textual (use web_scrape for PDFs or rendered pages)'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param content_type [String] normalized content-type (no +charset+
|
|
112
|
+
# parameter, lowercased) as produced by {Scraper::Simple.fetch}
|
|
113
|
+
# @return [Boolean] true when the content-type is +text/*+ or one
|
|
114
|
+
# of {TEXTUAL_APPLICATION_TYPES}
|
|
115
|
+
def self.textual?(content_type)
|
|
116
|
+
content_type.start_with?('text/') ||
|
|
117
|
+
TEXTUAL_APPLICATION_TYPES.include?(content_type)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Cut +body+ to at most +max_chars+ characters, appending a marker
|
|
121
|
+
# describing the original length when truncation actually happens.
|
|
122
|
+
# Returns +body+ unchanged if it already fits. Same shape as
|
|
123
|
+
# {Tool::WebScrape.truncate} so the LLM sees a consistent
|
|
124
|
+
# truncation marker across both tools.
|
|
125
|
+
#
|
|
126
|
+
# @param body [String] full response body
|
|
127
|
+
# @param max_chars [Integer] character cap; assumed already clamped
|
|
128
|
+
# @return [String]
|
|
129
|
+
def self.truncate(body, max_chars)
|
|
130
|
+
return body if body.length <= max_chars
|
|
131
|
+
|
|
132
|
+
"#{body[0, max_chars]}\n\n" \
|
|
133
|
+
"... [truncated at #{max_chars} of #{body.length} chars; " \
|
|
134
|
+
'call again with a larger `max_chars` to see more]'
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Verbatim URL download tool. Thin wrapper over {Tool::Fetch.fetch}
|
|
139
|
+
# that exposes it to the agent loop in OpenAI tool-call shape. Use for
|
|
140
|
+
# raw textual payloads (JSON APIs, CSV files, +robots.txt+, source
|
|
141
|
+
# files); use {Tool::WEB_SCRAPE} for rendered web pages or PDFs where
|
|
142
|
+
# readability extraction makes the result usable.
|
|
143
|
+
#
|
|
144
|
+
# @return [Tool]
|
|
145
|
+
FETCH = new(
|
|
146
|
+
name: 'fetch',
|
|
147
|
+
description: <<~DESC,
|
|
148
|
+
Downloads the given URL and returns its body verbatim.
|
|
149
|
+
|
|
150
|
+
Usage:
|
|
151
|
+
- Use for raw textual payloads: JSON APIs, CSV files, robots.txt, sitemaps, source files — anywhere a rendering pass would corrupt the data.
|
|
152
|
+
- For rendered HTML pages or PDFs, use web_scrape — it extracts readable content; fetch returns the raw HTML/PDF bytes unchanged.
|
|
153
|
+
- Accepts text/* and common textual application/* types (JSON, XML, JS, XHTML, RSS, Atom). Refuses PDFs, images, and other binaries.
|
|
154
|
+
DESC
|
|
155
|
+
parameters: Parameters.build { |p|
|
|
156
|
+
p.required_string :url,
|
|
157
|
+
'Absolute URL to download, including the scheme, ' \
|
|
158
|
+
'e.g. "https://example.com/data.json".'
|
|
159
|
+
p.optional_integer :max_chars,
|
|
160
|
+
'Maximum number of characters of the body to ' \
|
|
161
|
+
'return. Defaults to 5000; hard-capped at ' \
|
|
162
|
+
'100000. When the body is longer than this, ' \
|
|
163
|
+
'output is cut and a marker reports the full ' \
|
|
164
|
+
'length.'
|
|
165
|
+
},
|
|
166
|
+
execute: ->(url:, max_chars: Fetch::DEFAULT_MAX_CHARS) {
|
|
167
|
+
Fetch.fetch(url, max_chars: max_chars)
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
end
|