pikuri 0.0.1 → 0.0.4
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 +43 -179
- data/lib/pikuri.rb +16 -162
- metadata +66 -123
- data/CHANGELOG.md +0 -62
- data/GETTING_STARTED.md +0 -223
- data/LICENSE +0 -21
- data/lib/pikuri/agent/chat_transport.rb +0 -41
- data/lib/pikuri/agent/context_window_detector.rb +0 -101
- data/lib/pikuri/agent/listener/in_memory_message_list.rb +0 -33
- data/lib/pikuri/agent/listener/message_listener.rb +0 -93
- data/lib/pikuri/agent/listener/step_limit.rb +0 -97
- data/lib/pikuri/agent/listener/terminal.rb +0 -137
- data/lib/pikuri/agent/listener/token_log.rb +0 -166
- data/lib/pikuri/agent/listener_list.rb +0 -113
- data/lib/pikuri/agent/message.rb +0 -61
- data/lib/pikuri/agent/synthesizer.rb +0 -120
- data/lib/pikuri/agent/tokens.rb +0 -56
- data/lib/pikuri/agent.rb +0 -286
- data/lib/pikuri/subprocess.rb +0 -166
- data/lib/pikuri/tool/bash.rb +0 -272
- data/lib/pikuri/tool/calculator.rb +0 -82
- data/lib/pikuri/tool/confirmer.rb +0 -96
- data/lib/pikuri/tool/edit.rb +0 -196
- data/lib/pikuri/tool/fetch.rb +0 -167
- data/lib/pikuri/tool/glob.rb +0 -310
- data/lib/pikuri/tool/grep.rb +0 -338
- data/lib/pikuri/tool/parameters.rb +0 -314
- data/lib/pikuri/tool/read.rb +0 -254
- data/lib/pikuri/tool/scraper/fetch_error.rb +0 -16
- data/lib/pikuri/tool/scraper/html.rb +0 -285
- data/lib/pikuri/tool/scraper/pdf.rb +0 -54
- data/lib/pikuri/tool/scraper/simple.rb +0 -177
- data/lib/pikuri/tool/search/brave.rb +0 -184
- data/lib/pikuri/tool/search/duckduckgo.rb +0 -196
- data/lib/pikuri/tool/search/engines.rb +0 -154
- data/lib/pikuri/tool/search/exa.rb +0 -217
- data/lib/pikuri/tool/search/rate_limiter.rb +0 -92
- data/lib/pikuri/tool/search/result.rb +0 -29
- data/lib/pikuri/tool/skill.rb +0 -80
- data/lib/pikuri/tool/skill_catalog.rb +0 -376
- data/lib/pikuri/tool/sub_agent.rb +0 -102
- data/lib/pikuri/tool/web_scrape.rb +0 -117
- data/lib/pikuri/tool/web_search.rb +0 -38
- data/lib/pikuri/tool/workspace.rb +0 -150
- data/lib/pikuri/tool/write.rb +0 -170
- data/lib/pikuri/tool.rb +0 -118
- data/lib/pikuri/url_cache.rb +0 -106
- data/lib/pikuri/version.rb +0 -10
- data/prompts/coding-system-prompt.txt +0 -28
- data/prompts/pikuri-chat.txt +0 -15
data/lib/pikuri/subprocess.rb
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
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! }
|
data/lib/pikuri/tool/bash.rb
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'rainbow'
|
|
4
|
-
|
|
5
|
-
module Pikuri
|
|
6
|
-
class Tool
|
|
7
|
-
# The +bash+ tool — run an arbitrary shell command in the workspace.
|
|
8
|
-
# Instantiating +Tool::Bash.new(workspace: ws, confirmer: c)+ produces
|
|
9
|
-
# a tool whose {Tool#to_ruby_llm_tool} wiring is identical to any
|
|
10
|
-
# bundled tool's. Same shape as {Tool::Write} (workspace + confirmer
|
|
11
|
-
# captured by the +execute+ closure at construction).
|
|
12
|
-
#
|
|
13
|
-
# == Confirmation
|
|
14
|
-
#
|
|
15
|
-
# Every command requires confirmation. Bash composes the full prompt
|
|
16
|
-
# (header line, +$ <command>+ echo, +(y/n)?+ cue) and hands it to the
|
|
17
|
-
# confirmer; the confirmer renders + parses the answer. The command
|
|
18
|
-
# echo passes through {.visible} which escapes control bytes so the
|
|
19
|
-
# model can't smuggle a +\r\033[2K rm -rf ~/+ behind the displayed
|
|
20
|
-
# text. Execution uses the raw command; only the *display* is
|
|
21
|
-
# sanitized.
|
|
22
|
-
#
|
|
23
|
-
# == Subprocess wiring
|
|
24
|
-
#
|
|
25
|
-
# The command runs through {Pikuri::Subprocess.spawn} with argv:
|
|
26
|
-
#
|
|
27
|
-
# timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
|
|
28
|
-
#
|
|
29
|
-
# +bash -c+ (no +-l+) — no profile/rc sourcing, no inherited login
|
|
30
|
-
# environment beyond what pikuri itself runs in. +timeout(1)+ from
|
|
31
|
-
# GNU coreutils handles the SIGTERM-then-SIGKILL race; we never use
|
|
32
|
-
# Ruby's +Timeout.timeout+ (can't reliably kill subprocesses). The
|
|
33
|
-
# +--kill-after=5s+ gives the command 5 seconds to handle SIGTERM
|
|
34
|
-
# cleanly before SIGKILL is sent.
|
|
35
|
-
#
|
|
36
|
-
# == Timeout detection
|
|
37
|
-
#
|
|
38
|
-
# GNU coreutils' +timeout+ exits +124+ after a successful SIGTERM,
|
|
39
|
-
# +137+ after escalating to SIGKILL. We treat both as "command timed
|
|
40
|
-
# out". A third code, +125+, is also accepted: uutils-coreutils 0.2.2
|
|
41
|
-
# (the Rust reimplementation shipped on some distros) sends the
|
|
42
|
-
# SIGTERM correctly but then mis-reports +125+ ("timeout itself
|
|
43
|
-
# failed") instead of +124+ whenever +--kill-after+ is in play. False-
|
|
44
|
-
# positive risk on real GNU coreutils is low — our argv is fixed and
|
|
45
|
-
# well-formed, so a +125+ there would indicate a misconfigured PATH
|
|
46
|
-
# rather than a routine timeout.
|
|
47
|
-
#
|
|
48
|
-
# Caveat: +137+ is ambiguous — a command killed by the OOM-killer
|
|
49
|
-
# also exits +137+. v1 accepts the mis-classification; the
|
|
50
|
-
# observation tells the user "sent SIGTERM, then SIGKILL" regardless.
|
|
51
|
-
#
|
|
52
|
-
# == Output handling
|
|
53
|
-
#
|
|
54
|
-
# Combined stdout+stderr (popen2e). Head+tail truncation at
|
|
55
|
-
# {OUTPUT_HEAD} + {OUTPUT_TAIL} bytes with a marker reporting both
|
|
56
|
-
# bytes-omitted and the original total — the model needs the scale
|
|
57
|
-
# to decide whether to re-run with +head+/+tail+/+grep+.
|
|
58
|
-
#
|
|
59
|
-
# == Backgrounded subprocesses
|
|
60
|
-
#
|
|
61
|
-
# Plain +cmd &+ does NOT detach — the backgrounded child inherits our
|
|
62
|
-
# combined-output pipe by default, so {Pikuri::Subprocess#wait} blocks
|
|
63
|
-
# on +io.read+ until the backgrounded process exits. The model must
|
|
64
|
-
# redirect fds away from our pipe to genuinely background:
|
|
65
|
-
# +cmd >/dev/null 2>&1 &+. Such commands stay in our pgroup and get
|
|
66
|
-
# SIGTERM on pikuri exit via {Pikuri::Subprocess.cleanup!}; +nohup+ /
|
|
67
|
-
# +setsid+ plus redirection opt out of cleanup.
|
|
68
|
-
#
|
|
69
|
-
# == Refusals
|
|
70
|
-
#
|
|
71
|
-
# All returned as +"Error: ..."+ observations:
|
|
72
|
-
#
|
|
73
|
-
# * Empty / whitespace-only +command+ → fast reject before confirming.
|
|
74
|
-
# * +timeout+ outside +[1, MAX_TIMEOUT]+ → bounds error.
|
|
75
|
-
# * User declined the confirmation → +"Error: user declined ..."+.
|
|
76
|
-
# * +timeout+ exit (+124+ / +137+) → timeout error with partial output.
|
|
77
|
-
class Bash < Tool
|
|
78
|
-
# Pikuri-convention per-module logger; the +Bash+ progname tags the
|
|
79
|
-
# construction-time warning below so it's clear in the shared
|
|
80
|
-
# +Pikuri.log_io+ stream which tool issued it.
|
|
81
|
-
# @return [Logger]
|
|
82
|
-
LOGGER = Pikuri.logger_for('Bash')
|
|
83
|
-
|
|
84
|
-
# @return [Integer] default value of the +timeout+ parameter (seconds).
|
|
85
|
-
DEFAULT_TIMEOUT = 120
|
|
86
|
-
|
|
87
|
-
# @return [Integer] hard upper bound on the +timeout+ parameter.
|
|
88
|
-
MAX_TIMEOUT = 600
|
|
89
|
-
|
|
90
|
-
# @return [String] grace period between SIGTERM and SIGKILL,
|
|
91
|
-
# passed to +timeout --kill-after=...+.
|
|
92
|
-
KILL_AFTER = '5s'
|
|
93
|
-
|
|
94
|
-
# @return [Integer] bytes preserved from the start of the output
|
|
95
|
-
# when the combined-output stream exceeds {OUTPUT_HEAD} + {OUTPUT_TAIL}.
|
|
96
|
-
OUTPUT_HEAD = 15 * 1024
|
|
97
|
-
|
|
98
|
-
# @return [Integer] bytes preserved from the end of the output.
|
|
99
|
-
OUTPUT_TAIL = 15 * 1024
|
|
100
|
-
|
|
101
|
-
# Description shown to the LLM. opencode-shape: summary + +Usage:+
|
|
102
|
-
# bullets. Per-parameter constraints (default, max) live in the
|
|
103
|
-
# parameter descriptions.
|
|
104
|
-
#
|
|
105
|
-
# @return [String]
|
|
106
|
-
DESCRIPTION = <<~DESC
|
|
107
|
-
Run a bash command in the workspace.
|
|
108
|
-
|
|
109
|
-
Usage:
|
|
110
|
-
- Use for tasks the dedicated tools can't do: git, tests, package managers, multi-step shell pipelines.
|
|
111
|
-
- Prefer `read` / `write` / `edit` / `grep` / `glob` over `cat` / `sed` / `rg` / `find` — they're workspace-resolved.
|
|
112
|
-
- Each call starts in the workspace root; there is no pwd persistence between calls. Use `cd /path && cmd` in one command.
|
|
113
|
-
- stdin is closed; interactive commands hang until timeout. Use non-interactive flags (`apt -y`, `git commit -m`).
|
|
114
|
-
- Plain `cmd &` does NOT detach — the backgrounded process inherits our output pipe and blocks. To genuinely background, redirect fds: `cmd >/dev/null 2>&1 &`. Add `nohup` or `setsid` to survive pikuri exit.
|
|
115
|
-
- Combined stdout+stderr is returned. Suppress either via `2>/dev/null` etc.
|
|
116
|
-
- Large outputs are head+tail-truncated. Pipe through `head`/`tail`/`grep`/`wc` to control volume.
|
|
117
|
-
- Non-zero exit status is a normal observation, not an error.
|
|
118
|
-
- The user must confirm every command before it runs; on rejection an Error is returned.
|
|
119
|
-
DESC
|
|
120
|
-
|
|
121
|
-
# @param workspace [Tool::Workspace] captured for +chdir+; commands
|
|
122
|
-
# run in +workspace.cwd+. Bash does NOT path-resolve individual
|
|
123
|
-
# arguments — the +command+ string is opaque shell syntax.
|
|
124
|
-
# @param confirmer [Tool::Confirmer] consulted before every command.
|
|
125
|
-
# @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+;
|
|
126
|
-
# fail-loud at construction rather than the first tool call.
|
|
127
|
-
# @return [Bash]
|
|
128
|
-
def initialize(workspace:, confirmer:)
|
|
129
|
-
Bash.send(:check_binaries!)
|
|
130
|
-
# Prototype-stage capability advisory. The Bash tool runs commands
|
|
131
|
-
# with pikuri's own UID and inherits its filesystem view — there is
|
|
132
|
-
# no chroot, container, seccomp, or syscall filter today. Anything
|
|
133
|
-
# readable to the user is readable to the LLM via +cat ~/.ssh/id_*+
|
|
134
|
-
# / +aws configure list+ / etc. The per-command +Confirmer+ is the
|
|
135
|
-
# only line of defense; logged loud so anyone wiring this tool up
|
|
136
|
-
# sees the warning at startup, not on the day of an incident.
|
|
137
|
-
LOGGER.warn(
|
|
138
|
-
'Tool::Bash is a prototype: commands run unsandboxed under your UID and can read ' \
|
|
139
|
-
'sensitive files (~/.ssh, AWS credentials, browser sessions, ...). Use with care; ' \
|
|
140
|
-
'a future release will gate this behind a sandbox.'
|
|
141
|
-
)
|
|
142
|
-
super(
|
|
143
|
-
name: 'bash',
|
|
144
|
-
description: DESCRIPTION,
|
|
145
|
-
parameters: Parameters.build { |p|
|
|
146
|
-
p.required_string :command,
|
|
147
|
-
'Bash command to execute. Multi-line is fine. ' \
|
|
148
|
-
'Example: "ls -la lib/".'
|
|
149
|
-
p.optional_string :description,
|
|
150
|
-
'Short 3-7 word label shown to the user alongside ' \
|
|
151
|
-
'the command, e.g. "Run unit tests".'
|
|
152
|
-
p.optional_integer :timeout,
|
|
153
|
-
"Timeout in seconds. Defaults to #{DEFAULT_TIMEOUT}, " \
|
|
154
|
-
"max #{MAX_TIMEOUT}, e.g. 300."
|
|
155
|
-
},
|
|
156
|
-
execute: ->(command:, description: nil, timeout: DEFAULT_TIMEOUT) {
|
|
157
|
-
Bash.run(workspace: workspace, confirmer: confirmer,
|
|
158
|
-
command: command, description: description, timeout: timeout)
|
|
159
|
-
}
|
|
160
|
-
)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Bounds-check, confirm, spawn, and render the observation. Returns
|
|
164
|
-
# either +"$ ...\n<out>\n\nexit status: N"+ on a normal exit, or
|
|
165
|
-
# +"Error: ..."+ on rejection / timeout / bad inputs.
|
|
166
|
-
#
|
|
167
|
-
# @param workspace [Tool::Workspace]
|
|
168
|
-
# @param confirmer [Tool::Confirmer]
|
|
169
|
-
# @param command [String] raw command as supplied by the LLM
|
|
170
|
-
# @param description [String, nil] optional short label for the user
|
|
171
|
-
# @param timeout [Integer] seconds before SIGTERM is sent
|
|
172
|
-
# @return [String]
|
|
173
|
-
def self.run(workspace:, confirmer:, command:, description:, timeout:)
|
|
174
|
-
return 'Error: empty bash command.' if command.strip.empty?
|
|
175
|
-
return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1
|
|
176
|
-
return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT
|
|
177
|
-
|
|
178
|
-
prompt = compose_prompt(command: command, description: description, timeout: timeout)
|
|
179
|
-
return 'Error: user declined the bash command.' unless confirmer.confirm?(prompt: prompt)
|
|
180
|
-
|
|
181
|
-
result = Pikuri::Subprocess.spawn(
|
|
182
|
-
'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s",
|
|
183
|
-
'bash', '-c', command,
|
|
184
|
-
chdir: workspace.cwd.to_s
|
|
185
|
-
).wait
|
|
186
|
-
|
|
187
|
-
output = truncate(result.output)
|
|
188
|
-
exit_code = result.status.exitstatus
|
|
189
|
-
|
|
190
|
-
# 124 (GNU SIGTERM), 137 (GNU SIGKILL), 125 (uutils-coreutils
|
|
191
|
-
# 0.2.2 bug when --kill-after is set). See class header.
|
|
192
|
-
if exit_code == 124 || exit_code == 137 || exit_code == 125
|
|
193
|
-
"Error: command timed out after #{timeout}s (sent SIGTERM, then SIGKILL).\n\n" \
|
|
194
|
-
"$ #{visible(command)}\n#{output}"
|
|
195
|
-
else
|
|
196
|
-
"$ #{visible(command)}\n#{output}\n\nexit status: #{exit_code}"
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Compose the multi-line confirmation prompt. Three lines:
|
|
201
|
-
#
|
|
202
|
-
# 1. +OK to run bash[: <desc>][ \[Timeout: Ns\]]+ (bold)
|
|
203
|
-
# 2. +$ <visible(command)>+ (dim)
|
|
204
|
-
# 3. +(y/n)?+
|
|
205
|
-
#
|
|
206
|
-
# Colon after +bash+ is dropped when there's no description, since a
|
|
207
|
-
# trailing colon with nothing after it reads as broken. Timeout
|
|
208
|
-
# suffix appears only when non-default.
|
|
209
|
-
#
|
|
210
|
-
# @return [String]
|
|
211
|
-
def self.compose_prompt(command:, description:, timeout:)
|
|
212
|
-
header = +'OK to run bash'
|
|
213
|
-
desc_clean = description&.strip
|
|
214
|
-
header << ": #{desc_clean}" if desc_clean && !desc_clean.empty?
|
|
215
|
-
header << " [Timeout: #{timeout}s]" if timeout != DEFAULT_TIMEOUT
|
|
216
|
-
|
|
217
|
-
[
|
|
218
|
-
Rainbow(header).bold,
|
|
219
|
-
Rainbow("$ #{visible(command)}").dimgray,
|
|
220
|
-
'(y/n)?'
|
|
221
|
-
].join("\n")
|
|
222
|
-
end
|
|
223
|
-
private_class_method :compose_prompt
|
|
224
|
-
|
|
225
|
-
# Escape control bytes for safe display while preserving +\n+
|
|
226
|
-
# (multi-line shell commands are normal). Catches +\r+, +\x1b+
|
|
227
|
-
# (ESC, the ANSI introducer), +\b+, NUL, DEL, etc. — without this,
|
|
228
|
-
# a model could craft +command: "\rrm -rf ~/"+ that visually
|
|
229
|
-
# overwrites the echo line after the user has already read it.
|
|
230
|
-
#
|
|
231
|
-
# @param command [String]
|
|
232
|
-
# @return [String]
|
|
233
|
-
def self.visible(command)
|
|
234
|
-
command.gsub(/[\x00-\x09\x0b-\x1f\x7f]/) { |c| format('\\x%02x', c.ord) }
|
|
235
|
-
end
|
|
236
|
-
private_class_method :visible
|
|
237
|
-
|
|
238
|
-
# Head+tail-truncate +output+ to {OUTPUT_HEAD} + {OUTPUT_TAIL}
|
|
239
|
-
# bytes with a marker reporting both bytes-omitted and total.
|
|
240
|
-
# No-op when the output already fits.
|
|
241
|
-
#
|
|
242
|
-
# @param output [String]
|
|
243
|
-
# @return [String]
|
|
244
|
-
def self.truncate(output)
|
|
245
|
-
total = output.bytesize
|
|
246
|
-
return output if total <= OUTPUT_HEAD + OUTPUT_TAIL
|
|
247
|
-
|
|
248
|
-
omitted = total - (OUTPUT_HEAD + OUTPUT_TAIL)
|
|
249
|
-
head = output.byteslice(0, OUTPUT_HEAD)
|
|
250
|
-
tail = output.byteslice(total - OUTPUT_TAIL, OUTPUT_TAIL)
|
|
251
|
-
"#{head}\n... [#{omitted} bytes omitted; total was #{total} bytes] ...\n#{tail}"
|
|
252
|
-
end
|
|
253
|
-
private_class_method :truncate
|
|
254
|
-
|
|
255
|
-
# Verify +bash+ and GNU +timeout+ are reachable on +PATH+. Routed
|
|
256
|
-
# through {Pikuri::Subprocess.spawn} to honor the subprocess seam
|
|
257
|
-
# (no direct +system+ / +backtick+ in +lib/+). +bash+ missing
|
|
258
|
-
# surfaces as +Errno::ENOENT+ from +popen2e+; +timeout+ missing
|
|
259
|
-
# surfaces as a non-zero exit from +command -v+.
|
|
260
|
-
#
|
|
261
|
-
# @return [void]
|
|
262
|
-
# @raise [RuntimeError] if either binary is missing
|
|
263
|
-
def self.check_binaries!
|
|
264
|
-
result = Pikuri::Subprocess.spawn('bash', '-c', 'command -v timeout >/dev/null', chdir: '/').wait
|
|
265
|
-
raise "Tool::Bash requires GNU coreutils 'timeout' on PATH" unless result.status.success?
|
|
266
|
-
rescue Errno::ENOENT
|
|
267
|
-
raise "Tool::Bash requires 'bash' on PATH"
|
|
268
|
-
end
|
|
269
|
-
private_class_method :check_binaries!
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
end
|
|
@@ -1,82 +0,0 @@
|
|
|
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
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Pikuri
|
|
4
|
-
class Tool
|
|
5
|
-
# Port for asking the user to confirm a potentially destructive tool
|
|
6
|
-
# operation — currently {Tool::Bash} (every command) and
|
|
7
|
-
# {Tool::Write} (overwrite of an existing file with non-identical
|
|
8
|
-
# content). Subclass and implement {#confirm?}.
|
|
9
|
-
#
|
|
10
|
-
# == Why a Boolean return
|
|
11
|
-
#
|
|
12
|
-
# v1 returns +true+ or +false+. Two paths-not-taken worth recording
|
|
13
|
-
# so a future reader knows the design space was considered:
|
|
14
|
-
#
|
|
15
|
-
# 1. Richer return (+:once+ / +:always+ / +:reject+) — rejected
|
|
16
|
-
# because it creates decision fatigue, and the long-term answer
|
|
17
|
-
# is to make confirmations rare rather than smart (sandboxing,
|
|
18
|
-
# agentic destructiveness analysis).
|
|
19
|
-
# 2. Agentic destructive-or-not classifier — deferred to v2.
|
|
20
|
-
#
|
|
21
|
-
# The intended escape from confirmation prompts today is sandboxing
|
|
22
|
-
# (docker / dev-container) plus the +--yolo+ flag on +bin/pikuri-code+
|
|
23
|
-
# (which wires {AUTO_APPROVE} instead of {TERMINAL}).
|
|
24
|
-
#
|
|
25
|
-
# == Seam discipline
|
|
26
|
-
#
|
|
27
|
-
# Tools that need confirmation take a {Confirmer} via constructor and
|
|
28
|
-
# invoke {#confirm?} with a fully-composed prompt String. Tools do
|
|
29
|
-
# *not* call +gets+ / +puts+ directly — same lesson as listeners,
|
|
30
|
-
# keep IO at the seam so a future TUI / web client can plug a
|
|
31
|
-
# different implementation in without touching tool code.
|
|
32
|
-
class Confirmer
|
|
33
|
-
# @param prompt [String] human-readable question composed by the
|
|
34
|
-
# calling tool. The confirmer renders it and parses the answer;
|
|
35
|
-
# it does NOT compose its own prompt content. Caller owns the
|
|
36
|
-
# closing punctuation and any "(y/n)" cue.
|
|
37
|
-
# @return [Boolean] +true+ iff approved
|
|
38
|
-
# @raise [NotImplementedError] in the abstract base
|
|
39
|
-
def confirm?(prompt:)
|
|
40
|
-
raise NotImplementedError, "#{self.class}#confirm? must be implemented"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Stdin/stdout implementation: prints +prompt+ on its own line (a
|
|
44
|
-
# leading +puts+ guarantees separation from any streamed output
|
|
45
|
-
# the +Terminal+ listener may have produced just above), reads one
|
|
46
|
-
# line from +$stdin+, parses it strictly:
|
|
47
|
-
#
|
|
48
|
-
# * +"y"+ / +"yes"+ (case-insensitive, stripped) → +true+
|
|
49
|
-
# * +"n"+ / +"no"+ → +false+
|
|
50
|
-
# * EOF / Ctrl+D (+gets+ returns +nil+) → +false+, deliberate abort
|
|
51
|
-
# * anything else (blank, typo, +"maybe"+) → re-prompt with a short
|
|
52
|
-
# "Please answer y or n: " line and loop
|
|
53
|
-
#
|
|
54
|
-
# No retry cap; EOF eventually breaks adversarial input.
|
|
55
|
-
class Terminal < Confirmer
|
|
56
|
-
# @param prompt [String]
|
|
57
|
-
# @return [Boolean]
|
|
58
|
-
def confirm?(prompt:)
|
|
59
|
-
puts
|
|
60
|
-
puts prompt
|
|
61
|
-
$stdout.flush
|
|
62
|
-
loop do
|
|
63
|
-
line = $stdin.gets
|
|
64
|
-
return false if line.nil?
|
|
65
|
-
|
|
66
|
-
answer = line.strip.downcase
|
|
67
|
-
return true if answer == 'y' || answer == 'yes'
|
|
68
|
-
return false if answer == 'n' || answer == 'no'
|
|
69
|
-
|
|
70
|
-
print 'Please answer y or n: '
|
|
71
|
-
$stdout.flush
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Approves everything. Used by +bin/pikuri-code --yolo+ (docker /
|
|
77
|
-
# dev-container mode) and by tool specs that don't want to
|
|
78
|
-
# coordinate stdin. The name +AUTO_APPROVE+ matches the public
|
|
79
|
-
# constant {AUTO_APPROVE}.
|
|
80
|
-
class AutoApprove < Confirmer
|
|
81
|
-
# @param prompt [String] ignored
|
|
82
|
-
# @return [true]
|
|
83
|
-
def confirm?(prompt:)
|
|
84
|
-
true
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Shared singleton instance of {Terminal}. Stateless; reusable
|
|
89
|
-
# across tools and sub-agents.
|
|
90
|
-
TERMINAL = Terminal.new
|
|
91
|
-
|
|
92
|
-
# Shared singleton instance of {AutoApprove}.
|
|
93
|
-
AUTO_APPROVE = AutoApprove.new
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|