letsdo 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +291 -0
- data/bin/letsdo +33 -0
- data/lib/letsdo/agent.rb +45 -0
- data/lib/letsdo/agent_loop.rb +170 -0
- data/lib/letsdo/backlog_tasks.rb +60 -0
- data/lib/letsdo/cli.rb +207 -0
- data/lib/letsdo/errors.rb +22 -0
- data/lib/letsdo/loop.rb +58 -0
- data/lib/letsdo/output_streamer.rb +282 -0
- data/lib/letsdo/pi_runner.rb +260 -0
- data/lib/letsdo/prompt_store.rb +39 -0
- data/lib/letsdo/tui/input.rb +93 -0
- data/lib/letsdo/tui/log_buffer.rb +111 -0
- data/lib/letsdo/tui/metrics.rb +97 -0
- data/lib/letsdo/tui/renderer.rb +173 -0
- data/lib/letsdo/tui/session.rb +253 -0
- data/lib/letsdo/tui/terminal.rb +67 -0
- data/lib/letsdo/tui.rb +24 -0
- data/lib/letsdo/version.rb +5 -0
- data/lib/letsdo.rb +30 -0
- metadata +121 -0
data/lib/letsdo/cli.rb
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
# Command-line argument parsing and running an agent orchestrator loop.
|
|
7
|
+
#
|
|
8
|
+
# CLI keeps the scaffold contract (TASK-20) and adds agent launching:
|
|
9
|
+
# letsdo — usage and agent list, exit code 1;
|
|
10
|
+
# letsdo --version — version, exit code 0;
|
|
11
|
+
# letsdo --help — usage, exit code 0;
|
|
12
|
+
# letsdo <name> — run the <name> agent in the orchestrator
|
|
13
|
+
# loop until SIGINT/SIGTERM: all open
|
|
14
|
+
# tasks assigned to the agent are done
|
|
15
|
+
# one run per task, the loop waits for
|
|
16
|
+
# new ones; clean exit code 0;
|
|
17
|
+
# letsdo <unknown name> — "Unknown agent: <name>" + list,
|
|
18
|
+
# exit code 1.
|
|
19
|
+
# letsdo <unknown option> — "letsdo: unknown option: X" + usage,
|
|
20
|
+
# exit code 1.
|
|
21
|
+
#
|
|
22
|
+
# Environment:
|
|
23
|
+
# LETSDO_ROOT project root (agents/ lives there); default — pwd.
|
|
24
|
+
# LETSDO_PI_FLAGS extra pi flags (split on whitespace; if unset —
|
|
25
|
+
# AGENT_PI_FLAGS is used for bin/agent
|
|
26
|
+
# compatibility).
|
|
27
|
+
# LETSDO_PI_COMMAND the pi command (default "pi"); overridable for
|
|
28
|
+
# tests/fake pi.
|
|
29
|
+
# AGENT_ASSIGNEE_HANDLE the agent's backlog assignee handle; default
|
|
30
|
+
# "@<name>" (the one rule: handle = name).
|
|
31
|
+
# LETSDO_WAIT_SECONDS retry interval when no tasks are open (if
|
|
32
|
+
# unset — AGENT_WAIT_SECONDS, default 10).
|
|
33
|
+
# LETSDO_BACKLOG_COMMAND the backlog CLI command (default "backlog");
|
|
34
|
+
# overridable for tests/fake backlog.
|
|
35
|
+
class CLI
|
|
36
|
+
USAGE = 'Usage: letsdo <agent_name>'
|
|
37
|
+
AGENTS_HEADER = 'Available agents:'
|
|
38
|
+
DEFAULT_WAIT_SECONDS = 10.0
|
|
39
|
+
|
|
40
|
+
# @param argv [Array<String>] command-line arguments
|
|
41
|
+
# @param env [Hash] process environment (LETSDO_ROOT, LETSDO_PI_FLAGS,
|
|
42
|
+
# AGENT_PI_FLAGS, AGENT_ASSIGNEE_HANDLE, LETSDO_WAIT_SECONDS,
|
|
43
|
+
# LETSDO_BACKLOG_COMMAND); injected in tests
|
|
44
|
+
# @param stdout [IO] stream for normal output (usage, --help, list)
|
|
45
|
+
# @param stderr [IO] stream for service output
|
|
46
|
+
# @param stdin [IO] keyboard stream (the TUI reads keys from it)
|
|
47
|
+
# @param sleeper [Proc, nil] waiting procedure for the loop (callable
|
|
48
|
+
# with the interval); injected in tests for deterministic stops
|
|
49
|
+
# @return [Integer] exit code: 0 — success (incl. loop stop), 1 — error,
|
|
50
|
+
# otherwise — pi exit code
|
|
51
|
+
def self.run(argv, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin, sleeper: nil)
|
|
52
|
+
new(env: env, stdout: stdout, stderr: stderr, stdin: stdin, sleeper: sleeper).run(argv)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
|
|
56
|
+
@env = env
|
|
57
|
+
@stdout = stdout
|
|
58
|
+
@stderr = stderr
|
|
59
|
+
@stdin = stdin
|
|
60
|
+
@sleeper = sleeper
|
|
61
|
+
@root = env.fetch('LETSDO_ROOT', Dir.pwd)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param argv [Array<String>] command-line arguments
|
|
65
|
+
# @return [Integer] exit code
|
|
66
|
+
def run(argv)
|
|
67
|
+
arg = argv[0]
|
|
68
|
+
case arg
|
|
69
|
+
when '--version', '-v'
|
|
70
|
+
@stdout.puts(VERSION)
|
|
71
|
+
0
|
|
72
|
+
when '--help', '-h'
|
|
73
|
+
print_usage(@stdout)
|
|
74
|
+
0
|
|
75
|
+
when nil
|
|
76
|
+
print_usage(@stderr)
|
|
77
|
+
1
|
|
78
|
+
else
|
|
79
|
+
if arg.start_with?('-')
|
|
80
|
+
@stderr.puts("letsdo: unknown option: #{arg}")
|
|
81
|
+
print_usage(@stderr)
|
|
82
|
+
1
|
|
83
|
+
else
|
|
84
|
+
run_agent(arg)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def run_agent(name)
|
|
92
|
+
# The prompt must exist before the loop starts: an unknown agent fails
|
|
93
|
+
# fast (exit 1) instead of spinning in the loop.
|
|
94
|
+
PromptStore.new(root: @root).read(name)
|
|
95
|
+
|
|
96
|
+
if tui?
|
|
97
|
+
run_agent_tui(name)
|
|
98
|
+
else
|
|
99
|
+
run_agent_plain(name)
|
|
100
|
+
end
|
|
101
|
+
rescue UnknownAgentError => e
|
|
102
|
+
@stderr.puts(e.message)
|
|
103
|
+
print_agents
|
|
104
|
+
1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The non-interactive path: the plain line-stream output, byte-identical
|
|
108
|
+
# to the pre-TUI behavior (pipes, CI, tests, TERM=dumb).
|
|
109
|
+
def run_agent_plain(name)
|
|
110
|
+
streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
|
|
111
|
+
agent = Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
|
|
112
|
+
command: pi_command)
|
|
113
|
+
handle = assignee_handle(name)
|
|
114
|
+
provider = BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
|
|
115
|
+
env: ENV.to_h.merge(@env))
|
|
116
|
+
loop = AgentLoop.new(name: name, handle: handle, agent: agent,
|
|
117
|
+
task_provider: -> { provider.call },
|
|
118
|
+
wait_seconds: wait_seconds, sleeper: @sleeper, stderr: @stderr)
|
|
119
|
+
loop.run
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The interactive path: full-screen TUI over the same orchestrator loop.
|
|
123
|
+
# The streamer and the loop's service messages land in one combined log
|
|
124
|
+
# buffer; the loop driver feeds the header metrics facade; the session
|
|
125
|
+
# controller renders everything from a background input thread and
|
|
126
|
+
# restores the terminal on every exit path. The loop itself runs on the
|
|
127
|
+
# calling thread exactly as in plain mode.
|
|
128
|
+
def run_agent_tui(name)
|
|
129
|
+
clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
130
|
+
handle = assignee_handle(name)
|
|
131
|
+
log = Tui::LogBuffer.new
|
|
132
|
+
metrics = Tui::Metrics.new(name: name, handle: handle, clock: clock,
|
|
133
|
+
on_run_start: ->(_label) { log.divider })
|
|
134
|
+
terminal = Tui::Terminal.new(stream: @stdout)
|
|
135
|
+
input = Tui::Input.new(stdin: @stdin)
|
|
136
|
+
streamer = OutputStreamer.new(log: log)
|
|
137
|
+
agent = Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
|
|
138
|
+
command: pi_command)
|
|
139
|
+
provider = BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
|
|
140
|
+
env: ENV.to_h.merge(@env))
|
|
141
|
+
loop = AgentLoop.new(name: name, handle: handle, agent: agent,
|
|
142
|
+
task_provider: -> { provider.call },
|
|
143
|
+
wait_seconds: wait_seconds, sleeper: @sleeper, stderr: log,
|
|
144
|
+
metrics: metrics)
|
|
145
|
+
session = Tui::Session.new(name: name, handle: handle, log: log, metrics: metrics,
|
|
146
|
+
terminal: terminal, input: input,
|
|
147
|
+
refresh: -> { provider.call },
|
|
148
|
+
wait_seconds: wait_seconds, clock: clock)
|
|
149
|
+
session.run { loop.run }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The TUI is engaged only when stdout and stdin are terminals and TERM
|
|
153
|
+
# is not dumb; otherwise the plain line-stream output (pipes, CI,
|
|
154
|
+
# tests) — no escape codes, no TUI.
|
|
155
|
+
def tui?
|
|
156
|
+
@stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The agent's assignee handle: AGENT_ASSIGNEE_HANDLE override, otherwise
|
|
160
|
+
# the one rule — '@' + agent name.
|
|
161
|
+
def assignee_handle(name)
|
|
162
|
+
env_handle = @env['AGENT_ASSIGNEE_HANDLE']
|
|
163
|
+
env_handle && !env_handle.strip.empty? ? env_handle : "@#{name}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def backlog_command
|
|
167
|
+
@env.fetch('LETSDO_BACKLOG_COMMAND', 'backlog')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The retry interval when no tasks are open: LETSDO_WAIT_SECONDS, then
|
|
171
|
+
# AGENT_WAIT_SECONDS (bin/agent-loop compatibility), default 10 seconds.
|
|
172
|
+
def wait_seconds
|
|
173
|
+
value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
|
|
174
|
+
value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
|
|
175
|
+
return DEFAULT_WAIT_SECONDS if value.empty?
|
|
176
|
+
|
|
177
|
+
Float(value)
|
|
178
|
+
rescue ArgumentError, TypeError
|
|
179
|
+
DEFAULT_WAIT_SECONDS
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def pi_command
|
|
183
|
+
@env.fetch('LETSDO_PI_COMMAND', PiRunner::COMMAND)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def print_usage(stream)
|
|
187
|
+
stream.puts(USAGE)
|
|
188
|
+
print_agents
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def print_agents
|
|
192
|
+
@stdout.puts(AGENTS_HEADER)
|
|
193
|
+
PromptStore.new(root: @root).list.each { |name| @stdout.puts(" #{name}") }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# LETSDO_PI_FLAGS → array of flags; empty value = no flags.
|
|
197
|
+
# For bin/agent compatibility, when LETSDO_PI_FLAGS is absent
|
|
198
|
+
# AGENT_PI_FLAGS is used.
|
|
199
|
+
def parse_pi_flags
|
|
200
|
+
value = @env['LETSDO_PI_FLAGS'].to_s
|
|
201
|
+
value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
|
|
202
|
+
return [] if value.strip.empty?
|
|
203
|
+
|
|
204
|
+
Shellwords.split(value)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Base error of the package.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# An agent with this name was not found in agents/.
|
|
8
|
+
class UnknownAgentError < Error
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
def initialize(name)
|
|
12
|
+
@name = name
|
|
13
|
+
super("Unknown agent: #{name}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raised by the signal handler to interrupt whatever the main thread is
|
|
18
|
+
# doing (reading pi output, waiting for tasks, ...) so the loop unwinds
|
|
19
|
+
# cleanly. Not a StandardError — nothing rescues it accidentally.
|
|
20
|
+
class Stopped < Exception
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/letsdo/loop.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Orchestrator: while the backlog has open tasks assigned to the agent,
|
|
5
|
+
# runs the agent (one run = one task). When there are no tasks — waits
|
|
6
|
+
# and checks again. Stopping — only from outside: #stop (usually by a
|
|
7
|
+
# SIGINT/SIGTERM handler, as in bin/agent-loop).
|
|
8
|
+
#
|
|
9
|
+
# The task provider and the runner are injected so the loop is testable
|
|
10
|
+
# without a real backlog and pi; by default they are assembled from the
|
|
11
|
+
# project environment (backlog CLI + Letsdo::Agent).
|
|
12
|
+
class Loop
|
|
13
|
+
# @param task_provider [Proc] callable → Array of open tasks
|
|
14
|
+
# (empty = no tasks; nil = the backlog state is unreadable,
|
|
15
|
+
# in this case the loop does not run the agent and retries)
|
|
16
|
+
# @param run_task [Proc] callable(task) → agent run exit code
|
|
17
|
+
# @param wait_seconds [Float] wait interval when there are no tasks
|
|
18
|
+
# @param sleeper [Proc] callable(Float) → waiting (injected in tests)
|
|
19
|
+
def initialize(task_provider:, run_task:, wait_seconds: 10.0, sleeper: nil)
|
|
20
|
+
@task_provider = task_provider
|
|
21
|
+
@run_task = run_task
|
|
22
|
+
@wait_seconds = wait_seconds
|
|
23
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
24
|
+
@stopped = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Requests a stop after the current step.
|
|
28
|
+
def stop
|
|
29
|
+
@stopped = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stopped?
|
|
33
|
+
@stopped
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Runs the loop; ends only via #stop.
|
|
37
|
+
#
|
|
38
|
+
# @return [Integer] number of completed agent runs
|
|
39
|
+
def run
|
|
40
|
+
runs = 0
|
|
41
|
+
until @stopped
|
|
42
|
+
tasks = @task_provider.call
|
|
43
|
+
if tasks.nil? || tasks.empty?
|
|
44
|
+
@sleeper.call(@wait_seconds)
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
tasks.each do |task|
|
|
49
|
+
break if @stopped
|
|
50
|
+
|
|
51
|
+
@run_task.call(task)
|
|
52
|
+
runs += 1
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
runs
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
# Routes pi output across two streams:
|
|
7
|
+
# stdout — only the agent's answer text (text_delta), no service lines;
|
|
8
|
+
# stderr — service lines: tool calls (name, arguments, result) as
|
|
9
|
+
# execution progress.
|
|
10
|
+
#
|
|
11
|
+
# Every action line gets a shared HH:MM:SS time prefix: the difference
|
|
12
|
+
# between prefixes shows how long ago an action happened and how long
|
|
13
|
+
# a tool ran. Format of service tool lines:
|
|
14
|
+
# HH:MM:SS ⚙ name: arguments — tool call on one line (for bash the
|
|
15
|
+
# command text, for read/write/edit the
|
|
16
|
+
# path, etc.);
|
|
17
|
+
# indented " " lines — execution result (stdout/stderr),
|
|
18
|
+
# no time prefix (data, not actions);
|
|
19
|
+
# HH:MM:SS ✓ name: done (Xs) — tool completion (success), printed
|
|
20
|
+
# after the result block;
|
|
21
|
+
# HH:MM:SS ✖ name: error (Xs) — tool completion (error);
|
|
22
|
+
# ✖ Error: ... — error result marked explicitly;
|
|
23
|
+
# … [output truncated: N lines, M] — big output → summary note.
|
|
24
|
+
# Remembers the last response character so that on finish a final newline
|
|
25
|
+
# is guaranteed — output must not break in the middle.
|
|
26
|
+
class OutputStreamer
|
|
27
|
+
# Max lines of a tool result in service output.
|
|
28
|
+
MAX_RESULT_LINES = 100
|
|
29
|
+
# Max characters of a tool result in service output.
|
|
30
|
+
MAX_RESULT_CHARS = 4_000
|
|
31
|
+
# Max characters of a short call-arguments representation.
|
|
32
|
+
MAX_ARGS_CHARS = 300
|
|
33
|
+
|
|
34
|
+
# @param stdout [IO] stream for the agent's answer text
|
|
35
|
+
# @param stderr [IO] stream for service lines
|
|
36
|
+
# @param log [Object, nil] an alternate log target (TUI mode): when
|
|
37
|
+
# given, ALL output — answer text and service tool lines — is
|
|
38
|
+
# written there as one combined stream (a
|
|
39
|
+
# Letsdo::Tui::LogBuffer); without it the plain stdout/stderr
|
|
40
|
+
# split is byte-identical to before
|
|
41
|
+
# @param clock [Proc] callable → Time, the source of time for prefixes
|
|
42
|
+
# (injected in tests for deterministic HH:MM:SS)
|
|
43
|
+
def initialize(stdout: $stdout, stderr: $stderr, log: nil, clock: nil)
|
|
44
|
+
@stdout = stdout
|
|
45
|
+
@stderr = stderr
|
|
46
|
+
@log = log
|
|
47
|
+
@clock = clock || -> { Time.now }
|
|
48
|
+
@last_char = nil
|
|
49
|
+
@action_started_at = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Prints a chunk of the agent's answer text immediately.
|
|
53
|
+
#
|
|
54
|
+
# @param delta [String] the next text fragment
|
|
55
|
+
def text_delta(delta)
|
|
56
|
+
return if delta.nil? || delta.empty?
|
|
57
|
+
|
|
58
|
+
text_sink.write(delta)
|
|
59
|
+
text_sink.flush unless @log
|
|
60
|
+
@last_char = delta[-1]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A service line about a tool call: HH:MM:SS time prefix, tool name and
|
|
64
|
+
# short arguments (e.g. the executed bash command text). One line without
|
|
65
|
+
# empty placeholders, to stderr so it does not mix with the agent text
|
|
66
|
+
# on stdout. The call moment is remembered — the action duration in the
|
|
67
|
+
# completion line is measured from it.
|
|
68
|
+
#
|
|
69
|
+
# @param name [String] tool name
|
|
70
|
+
# @param args [Hash, String, nil] call arguments (pi event)
|
|
71
|
+
def tool_start(name, args: nil)
|
|
72
|
+
@action_started_at = @clock.call
|
|
73
|
+
line = +"#{timestamp} ⚙ #{name}"
|
|
74
|
+
summary = summarize_args(name, args)
|
|
75
|
+
line << ": #{summary}" if summary
|
|
76
|
+
write_service("#{line}\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Service lines of the tool result (command/file output): an indented
|
|
80
|
+
# block without a time prefix (action data) plus a completion line with
|
|
81
|
+
# the HH:MM:SS prefix, name, verdict and duration. Very large output is
|
|
82
|
+
# trimmed neatly with a summary note, errors are marked explicitly.
|
|
83
|
+
# An empty result yields only the completion line.
|
|
84
|
+
#
|
|
85
|
+
# @param name [String] tool name
|
|
86
|
+
# @param text [String] result text
|
|
87
|
+
# @param error [Boolean] whether the execution failed
|
|
88
|
+
def tool_result(name, text, error: false)
|
|
89
|
+
out = String.new
|
|
90
|
+
unless text.nil? || text.empty?
|
|
91
|
+
lines = text.lines
|
|
92
|
+
kept, truncated = truncate_result(text)
|
|
93
|
+
kept.each_with_index do |line, index|
|
|
94
|
+
line = line.chomp
|
|
95
|
+
next if line.empty? && index == kept.length - 1 # no trailing empty line
|
|
96
|
+
|
|
97
|
+
prefix = index.zero? ? (error ? " ✖ Error: " : " ") : " "
|
|
98
|
+
out << "#{prefix}#{line}\n"
|
|
99
|
+
end
|
|
100
|
+
out << result_note(lines) if truncated
|
|
101
|
+
end
|
|
102
|
+
out << completion_line(name, error)
|
|
103
|
+
write_service(out)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Finishes the output: if the answer did not end with a newline — adds
|
|
107
|
+
# one so the next terminal output does not stick to the agent's answer.
|
|
108
|
+
def finish
|
|
109
|
+
return unless @last_char && @last_char != "\n"
|
|
110
|
+
|
|
111
|
+
text_sink.write("\n")
|
|
112
|
+
text_sink.flush unless @log
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Shared time prefix for all action lines: HH:MM:SS.
|
|
118
|
+
#
|
|
119
|
+
# @return [String] local time as hours:minutes:seconds
|
|
120
|
+
def timestamp
|
|
121
|
+
@clock.call.strftime("%H:%M:%S")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Tool completion line: time prefix, verdict, name and action duration
|
|
125
|
+
# (seconds between call and completion). The duration is not printed
|
|
126
|
+
# when no action start was recorded (e.g. a result without a preceding
|
|
127
|
+
# call).
|
|
128
|
+
#
|
|
129
|
+
# @param name [String] tool name
|
|
130
|
+
# @param error [Boolean] whether the execution failed
|
|
131
|
+
# @return [String] completion line with a trailing newline
|
|
132
|
+
def completion_line(name, error)
|
|
133
|
+
mark = error ? "✖" : "✓"
|
|
134
|
+
verdict = error ? "error" : "done"
|
|
135
|
+
elapsed = elapsed_seconds
|
|
136
|
+
duration = elapsed ? " (#{format_elapsed(elapsed)})" : ""
|
|
137
|
+
"#{timestamp} #{mark} #{name}: #{verdict}#{duration}\n"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Seconds from the current action start to its completion.
|
|
141
|
+
#
|
|
142
|
+
# @return [Float, nil] duration, or nil if there was no start
|
|
143
|
+
def elapsed_seconds
|
|
144
|
+
return nil unless @action_started_at
|
|
145
|
+
|
|
146
|
+
@clock.call - @action_started_at
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A neat duration: seconds with one decimal place up to 10s, whole
|
|
150
|
+
# seconds after.
|
|
151
|
+
def format_elapsed(seconds)
|
|
152
|
+
seconds < 10 ? format("%.1fs", seconds) : "#{seconds.round}s"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Truncates the result text by line and character limits.
|
|
156
|
+
#
|
|
157
|
+
# @param text [String] the whole result text
|
|
158
|
+
# @return [Array(Array<String>, Boolean)] kept lines and truncation flag
|
|
159
|
+
def truncate_result(text)
|
|
160
|
+
lines = text.lines
|
|
161
|
+
kept = []
|
|
162
|
+
chars = 0
|
|
163
|
+
truncated = false
|
|
164
|
+
lines.each do |line|
|
|
165
|
+
if kept.length >= MAX_RESULT_LINES || (!kept.empty? && chars + line.length > MAX_RESULT_CHARS)
|
|
166
|
+
truncated = true
|
|
167
|
+
break
|
|
168
|
+
end
|
|
169
|
+
kept << line
|
|
170
|
+
chars += line.length
|
|
171
|
+
end
|
|
172
|
+
# A single line longer than the char limit — cut the line itself.
|
|
173
|
+
if kept.first && kept.first.length > MAX_RESULT_CHARS
|
|
174
|
+
kept[0] = kept[0].slice(0, MAX_RESULT_CHARS)
|
|
175
|
+
truncated = true
|
|
176
|
+
end
|
|
177
|
+
[kept, truncated]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Summary note about the truncated result.
|
|
181
|
+
def result_note(lines)
|
|
182
|
+
total_lines = lines.length
|
|
183
|
+
total_chars = lines.sum(&:length)
|
|
184
|
+
lines_word = plural(total_lines, "line", "lines")
|
|
185
|
+
chars_word = plural(total_chars, "character", "characters")
|
|
186
|
+
" … [output truncated: #{total_lines} #{lines_word}, #{total_chars} #{chars_word}]\n"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# English singular/plural for a noun after a number.
|
|
190
|
+
def plural(count, singular, plural_form)
|
|
191
|
+
count == 1 ? singular : plural_form
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Short one-line representation of the tool call arguments.
|
|
195
|
+
#
|
|
196
|
+
# @param name [String] tool name
|
|
197
|
+
# @param args [Hash, String, nil] call arguments
|
|
198
|
+
# @return [String, nil] arguments string or nil (print nothing)
|
|
199
|
+
def summarize_args(name, args)
|
|
200
|
+
case name
|
|
201
|
+
when "bash", "powershell"
|
|
202
|
+
one_line(args_value(args, "command"))
|
|
203
|
+
when "read"
|
|
204
|
+
path = args_value(args, "path") || args_value(args, "file_path")
|
|
205
|
+
return nil unless path
|
|
206
|
+
|
|
207
|
+
range = read_range(args)
|
|
208
|
+
"#{path}#{range}"
|
|
209
|
+
when "write", "edit", "find", "ls"
|
|
210
|
+
args_value(args, "path") || args_value(args, "file_path")
|
|
211
|
+
when "grep"
|
|
212
|
+
pattern = one_line(args_value(args, "pattern"))
|
|
213
|
+
path = args_value(args, "path") || args_value(args, "file_path")
|
|
214
|
+
return nil unless pattern
|
|
215
|
+
|
|
216
|
+
path ? "#{pattern} #{path}" : pattern
|
|
217
|
+
else
|
|
218
|
+
summarize_generic(args)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Read line range (offset/limit) as ":N-M", when given.
|
|
223
|
+
def read_range(args)
|
|
224
|
+
return nil unless args.is_a?(Hash)
|
|
225
|
+
|
|
226
|
+
offset = args["offset"]
|
|
227
|
+
return nil unless offset
|
|
228
|
+
|
|
229
|
+
limit = args["limit"]
|
|
230
|
+
limit ? ":#{offset}-#{offset + limit - 1}" : ":#{offset}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Tool argument value as a string; nil when absent.
|
|
234
|
+
def args_value(args, key)
|
|
235
|
+
return nil unless args.is_a?(Hash)
|
|
236
|
+
|
|
237
|
+
value = args[key]
|
|
238
|
+
value.nil? ? nil : value.to_s
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Universal arguments representation (other tools): short JSON on one line.
|
|
242
|
+
def summarize_generic(args)
|
|
243
|
+
return nil if args.nil? || (args.is_a?(Hash) && args.empty?)
|
|
244
|
+
|
|
245
|
+
text =
|
|
246
|
+
case args
|
|
247
|
+
when String then args
|
|
248
|
+
when Hash then JSON.generate(args)
|
|
249
|
+
else args.to_s
|
|
250
|
+
end
|
|
251
|
+
one_line(text)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Collapses whitespace into a single line and cuts by the limit.
|
|
255
|
+
def one_line(value, max: MAX_ARGS_CHARS)
|
|
256
|
+
return nil if value.nil?
|
|
257
|
+
|
|
258
|
+
text = value.to_s.gsub(/\s+/, " ").strip
|
|
259
|
+
return nil if text.empty?
|
|
260
|
+
|
|
261
|
+
text.length > max ? "#{text.slice(0, max)}…" : text
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Writes a service line to stderr and flushes immediately so output
|
|
265
|
+
# appears as it is produced; in TUI mode the line goes to the shared
|
|
266
|
+
# log buffer instead.
|
|
267
|
+
def write_service(text)
|
|
268
|
+
if @log
|
|
269
|
+
@log.write(text)
|
|
270
|
+
else
|
|
271
|
+
@stderr.write(text)
|
|
272
|
+
@stderr.flush
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Where the agent's answer text goes: the shared log in TUI mode,
|
|
277
|
+
# stdout otherwise.
|
|
278
|
+
def text_sink
|
|
279
|
+
@log || @stdout
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|