letsdo 0.3.0 → 0.6.1
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/CHANGELOG.md +232 -0
- data/README.md +156 -23
- data/bin/letsdo +8 -1
- data/docs/config.md +176 -0
- data/docs/prompts.md +210 -0
- data/docs/task-selection.md +244 -0
- data/docs/usage.md +296 -0
- data/letsdo.gemspec +63 -0
- data/lib/letsdo/agent.rb +30 -17
- data/lib/letsdo/agent_identity.rb +46 -0
- data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
- data/lib/letsdo/agent_loop/tasks.rb +88 -13
- data/lib/letsdo/agent_loop.rb +46 -4
- data/lib/letsdo/backends/backend.rb +137 -0
- data/lib/letsdo/backends/pi/events.rb +77 -0
- data/lib/letsdo/backends/pi.rb +88 -0
- data/lib/letsdo/cli/builder.rb +102 -0
- data/lib/letsdo/cli/builder_assembly.rb +154 -0
- data/lib/letsdo/cli/builder_metrics.rb +82 -0
- data/lib/letsdo/cli/doctor.rb +16 -0
- data/lib/letsdo/cli.rb +17 -56
- data/lib/letsdo/config.rb +152 -0
- data/lib/letsdo/control/reader.rb +131 -0
- data/lib/letsdo/control.rb +6 -3
- data/lib/letsdo/doctor/checks.rb +144 -0
- data/lib/letsdo/doctor.rb +42 -0
- data/lib/letsdo/duration.rb +23 -0
- data/lib/letsdo/errors.rb +7 -0
- data/lib/letsdo/metrics/fanout.rb +47 -0
- data/lib/letsdo/prompt_store.rb +48 -1
- data/lib/letsdo/providers/backlog.rb +180 -0
- data/lib/letsdo/providers/task.rb +48 -0
- data/lib/letsdo/retry_policy.rb +98 -0
- data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
- data/lib/letsdo/session_recorder.rb +188 -0
- data/lib/letsdo/task_time_writeback.rb +106 -0
- data/lib/letsdo/tui/metrics.rb +10 -3
- data/lib/letsdo/tui/renderer.rb +9 -1
- data/lib/letsdo/tui/session/terminal.rb +47 -0
- data/lib/letsdo/tui/session/view.rb +10 -2
- data/lib/letsdo/tui/session.rb +27 -22
- data/lib/letsdo/tui/window_title.rb +133 -0
- data/lib/letsdo/tui.rb +4 -0
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo.rb +17 -5
- metadata +39 -13
- data/lib/letsdo/backlog_tasks.rb +0 -59
- data/lib/letsdo/cli/launch.rb +0 -87
- data/lib/letsdo/pi_runner/events.rb +0 -75
- data/lib/letsdo/pi_runner/process.rb +0 -68
- data/lib/letsdo/pi_runner.rb +0 -101
data/lib/letsdo.rb
CHANGED
|
@@ -6,32 +6,44 @@
|
|
|
6
6
|
# Letsdo::Errors - error hierarchy (Letsdo::Error and others)
|
|
7
7
|
# Letsdo::PromptStore - access to agents/*.md prompts
|
|
8
8
|
# Letsdo::DefaultPrompt - the built-in default prompt (fallback run, --init template)
|
|
9
|
+
# Letsdo::AgentIdentity - the identity block injected into every agent prompt
|
|
9
10
|
# Letsdo::OutputStreamer - where and how agent text and service lines are printed
|
|
10
|
-
# Letsdo::
|
|
11
|
+
# Letsdo::Backends::Pi - running pi --mode json and parsing the event stream
|
|
11
12
|
# Letsdo::Agent - a single agent run: prompt from agents/ or default + pi
|
|
12
13
|
# Letsdo::Capture - interruption-safe child stdout/stderr capture
|
|
13
14
|
# Letsdo::BacklogTasks - open tasks from the backlog CLI (the task provider)
|
|
14
15
|
# Letsdo::Loop - generic orchestrator: tasks → runs → waiting
|
|
16
|
+
# Letsdo::RetryPolicy - per-task retry/backoff/give-up policy (used at AgentLoop level)
|
|
15
17
|
# Letsdo::AgentLoop - letsdo wiring: provider + agent + signals + messages
|
|
16
|
-
# Letsdo::Control - agent-control primitives (PauseGate)
|
|
18
|
+
# Letsdo::Control - agent-control primitives (PauseGate, Reader)
|
|
19
|
+
# Letsdo::Doctor - `letsdo doctor` environment self-check
|
|
17
20
|
# Letsdo::Tui - the interactive TUI (LogBuffer, Metrics,
|
|
18
|
-
# Renderer, Terminal, Input, Session)
|
|
21
|
+
# Renderer, Terminal, WindowTitle, Input, Session)
|
|
19
22
|
# Letsdo::CLI - command-line arguments, usage, exit code
|
|
20
23
|
#
|
|
21
24
|
# Entry point — bin/letsdo (a thin wrapper over Letsdo::CLI).
|
|
22
25
|
|
|
23
26
|
require_relative 'letsdo/version'
|
|
24
27
|
require_relative 'letsdo/errors'
|
|
28
|
+
require_relative 'letsdo/duration'
|
|
29
|
+
require_relative 'letsdo/config'
|
|
25
30
|
require_relative 'letsdo/prompt_store'
|
|
26
31
|
require_relative 'letsdo/default_prompt'
|
|
32
|
+
require_relative 'letsdo/agent_identity'
|
|
27
33
|
require_relative 'letsdo/output_streamer'
|
|
28
|
-
require_relative 'letsdo/
|
|
34
|
+
require_relative 'letsdo/backends/backend'
|
|
35
|
+
require_relative 'letsdo/backends/pi'
|
|
29
36
|
require_relative 'letsdo/agent'
|
|
30
37
|
require_relative 'letsdo/capture'
|
|
31
|
-
require_relative 'letsdo/
|
|
38
|
+
require_relative 'letsdo/providers/backlog'
|
|
32
39
|
require_relative 'letsdo/loop'
|
|
40
|
+
require_relative 'letsdo/retry_policy'
|
|
33
41
|
require_relative 'letsdo/watcher'
|
|
34
42
|
require_relative 'letsdo/agent_loop'
|
|
43
|
+
require_relative 'letsdo/session_recorder'
|
|
44
|
+
require_relative 'letsdo/task_time_writeback'
|
|
45
|
+
require_relative 'letsdo/metrics/fanout'
|
|
35
46
|
require_relative 'letsdo/control'
|
|
47
|
+
require_relative 'letsdo/doctor'
|
|
36
48
|
require_relative 'letsdo/tui'
|
|
37
49
|
require_relative 'letsdo/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: letsdo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergei O. Udalov
|
|
@@ -79,10 +79,11 @@ dependencies:
|
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
81
|
version: 1.77.0
|
|
82
|
-
description:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
description: letsdo turns a plain markdown backlog into a team of autonomous local
|
|
83
|
+
agents. Each agent is just a prompt file in agents/; run `letsdo <name>` and it
|
|
84
|
+
works through every open task assigned to that agent, one task per run, and stops
|
|
85
|
+
cleanly on Ctrl+C. No framework, no hosted platform — the backlog folder is the
|
|
86
|
+
single source of truth and task data never leaves your machine.
|
|
86
87
|
email:
|
|
87
88
|
- udalov.x@mail.ru
|
|
88
89
|
executables:
|
|
@@ -90,28 +91,50 @@ executables:
|
|
|
90
91
|
extensions: []
|
|
91
92
|
extra_rdoc_files: []
|
|
92
93
|
files:
|
|
94
|
+
- CHANGELOG.md
|
|
93
95
|
- LICENSE
|
|
94
96
|
- README.md
|
|
95
97
|
- bin/letsdo
|
|
98
|
+
- docs/config.md
|
|
99
|
+
- docs/prompts.md
|
|
100
|
+
- docs/task-selection.md
|
|
101
|
+
- docs/usage.md
|
|
102
|
+
- letsdo.gemspec
|
|
96
103
|
- lib/letsdo.rb
|
|
97
104
|
- lib/letsdo/agent.rb
|
|
105
|
+
- lib/letsdo/agent_identity.rb
|
|
98
106
|
- lib/letsdo/agent_loop.rb
|
|
107
|
+
- lib/letsdo/agent_loop/assignee_hints.rb
|
|
99
108
|
- lib/letsdo/agent_loop/tasks.rb
|
|
100
|
-
- lib/letsdo/
|
|
109
|
+
- lib/letsdo/backends/backend.rb
|
|
110
|
+
- lib/letsdo/backends/pi.rb
|
|
111
|
+
- lib/letsdo/backends/pi/events.rb
|
|
101
112
|
- lib/letsdo/capture.rb
|
|
102
113
|
- lib/letsdo/cli.rb
|
|
114
|
+
- lib/letsdo/cli/builder.rb
|
|
115
|
+
- lib/letsdo/cli/builder_assembly.rb
|
|
116
|
+
- lib/letsdo/cli/builder_metrics.rb
|
|
117
|
+
- lib/letsdo/cli/doctor.rb
|
|
103
118
|
- lib/letsdo/cli/init.rb
|
|
104
|
-
- lib/letsdo/
|
|
119
|
+
- lib/letsdo/config.rb
|
|
105
120
|
- lib/letsdo/control.rb
|
|
121
|
+
- lib/letsdo/control/reader.rb
|
|
106
122
|
- lib/letsdo/default_prompt.rb
|
|
123
|
+
- lib/letsdo/doctor.rb
|
|
124
|
+
- lib/letsdo/doctor/checks.rb
|
|
125
|
+
- lib/letsdo/duration.rb
|
|
107
126
|
- lib/letsdo/errors.rb
|
|
108
127
|
- lib/letsdo/loop.rb
|
|
128
|
+
- lib/letsdo/metrics/fanout.rb
|
|
109
129
|
- lib/letsdo/output_streamer.rb
|
|
110
130
|
- lib/letsdo/output_streamer/arg_summary.rb
|
|
111
|
-
- lib/letsdo/pi_runner.rb
|
|
112
|
-
- lib/letsdo/pi_runner/events.rb
|
|
113
|
-
- lib/letsdo/pi_runner/process.rb
|
|
114
131
|
- lib/letsdo/prompt_store.rb
|
|
132
|
+
- lib/letsdo/providers/backlog.rb
|
|
133
|
+
- lib/letsdo/providers/task.rb
|
|
134
|
+
- lib/letsdo/retry_policy.rb
|
|
135
|
+
- lib/letsdo/session_recorder.rb
|
|
136
|
+
- lib/letsdo/session_recorder/jsonl_writer.rb
|
|
137
|
+
- lib/letsdo/task_time_writeback.rb
|
|
115
138
|
- lib/letsdo/tui.rb
|
|
116
139
|
- lib/letsdo/tui/input.rb
|
|
117
140
|
- lib/letsdo/tui/log_buffer.rb
|
|
@@ -119,17 +142,20 @@ files:
|
|
|
119
142
|
- lib/letsdo/tui/renderer.rb
|
|
120
143
|
- lib/letsdo/tui/session.rb
|
|
121
144
|
- lib/letsdo/tui/session/keys.rb
|
|
145
|
+
- lib/letsdo/tui/session/terminal.rb
|
|
122
146
|
- lib/letsdo/tui/session/view.rb
|
|
123
147
|
- lib/letsdo/tui/terminal.rb
|
|
148
|
+
- lib/letsdo/tui/window_title.rb
|
|
124
149
|
- lib/letsdo/version.rb
|
|
125
150
|
- lib/letsdo/watcher.rb
|
|
126
151
|
homepage: https://github.com/sergio-fry/letsdo
|
|
127
152
|
licenses:
|
|
128
153
|
- MIT
|
|
129
154
|
metadata:
|
|
130
|
-
homepage_uri: https://github.com/sergio-fry/letsdo
|
|
131
155
|
source_code_uri: https://github.com/sergio-fry/letsdo
|
|
156
|
+
bug_tracker_uri: https://github.com/sergio-fry/letsdo/issues
|
|
132
157
|
changelog_uri: https://github.com/sergio-fry/letsdo/blob/main/CHANGELOG.md
|
|
158
|
+
documentation_uri: https://github.com/sergio-fry/letsdo/blob/main/README.md
|
|
133
159
|
rubygems_mfa_required: 'true'
|
|
134
160
|
allowed_push_host: https://rubygems.org
|
|
135
161
|
rdoc_options: []
|
|
@@ -139,14 +165,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
139
165
|
requirements:
|
|
140
166
|
- - ">="
|
|
141
167
|
- !ruby/object:Gem::Version
|
|
142
|
-
version: '3.
|
|
168
|
+
version: '3.3'
|
|
143
169
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
144
170
|
requirements:
|
|
145
171
|
- - ">="
|
|
146
172
|
- !ruby/object:Gem::Version
|
|
147
173
|
version: '0'
|
|
148
174
|
requirements: []
|
|
149
|
-
rubygems_version: 4.0.
|
|
175
|
+
rubygems_version: 4.0.21
|
|
150
176
|
specification_version: 4
|
|
151
177
|
summary: A local agent worker for Backlog.md/markdown tasks
|
|
152
178
|
test_files: []
|
data/lib/letsdo/backlog_tasks.rb
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'json'
|
|
4
|
-
require 'shellwords'
|
|
5
|
-
|
|
6
|
-
module Letsdo
|
|
7
|
-
# Task provider for Letsdo::Loop backed by the real backlog CLI:
|
|
8
|
-
#
|
|
9
|
-
# backlog task list --assignee <handle> --exclude-status Done --json
|
|
10
|
-
#
|
|
11
|
-
# Returns the list of open tasks assigned to the handle (an Array, empty
|
|
12
|
-
# when there are none), or nil when the backlog state is unreadable — the
|
|
13
|
-
# CLI is not on PATH, failed, or its output is not the expected JSON. The
|
|
14
|
-
# loop treats nil as "pause and retry, do not run the agent".
|
|
15
|
-
#
|
|
16
|
-
# The command runs in the project root (cwd), where the backlog CLI finds
|
|
17
|
-
# the backlog/ folder — the same context as a single agent run.
|
|
18
|
-
class BacklogTasks
|
|
19
|
-
# @param handle [String] assignee handle to filter by (e.g. "@developer")
|
|
20
|
-
# @param command [String] backlog CLI command (overridable for tests)
|
|
21
|
-
# @param cwd [String, nil] project root for the CLI; nil = inherit cwd
|
|
22
|
-
# @param env [Hash, nil] environment for the CLI child (nil = inherit
|
|
23
|
-
# the process environment; injected in tests to control the
|
|
24
|
-
# fake backlog scenarios)
|
|
25
|
-
def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
|
|
26
|
-
@handle = handle
|
|
27
|
-
@command = command
|
|
28
|
-
@cwd = cwd
|
|
29
|
-
@env = env
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Reads the open tasks once.
|
|
33
|
-
#
|
|
34
|
-
# @return [Array<Hash>, nil] open tasks; nil when the backlog is unreadable
|
|
35
|
-
def call
|
|
36
|
-
args = @env ? [@env, *command_line] : command_line
|
|
37
|
-
out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
|
|
38
|
-
return nil unless status.success?
|
|
39
|
-
|
|
40
|
-
tasks = JSON.parse(out)['tasks']
|
|
41
|
-
tasks.is_a?(Array) ? tasks : nil
|
|
42
|
-
rescue Errno::ENOENT, JSON::ParserError, TypeError
|
|
43
|
-
nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
# [command..., task, list, --assignee <handle>, --exclude-status Done, --json]
|
|
49
|
-
def command_line
|
|
50
|
-
[
|
|
51
|
-
*Shellwords.split(@command),
|
|
52
|
-
'task', 'list',
|
|
53
|
-
'--assignee', @handle,
|
|
54
|
-
'--exclude-status', 'Done',
|
|
55
|
-
'--json'
|
|
56
|
-
]
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
data/lib/letsdo/cli/launch.rb
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Letsdo
|
|
4
|
-
# Builds the agent, provider, and orchestrator loop for CLI run paths.
|
|
5
|
-
module CLILaunch
|
|
6
|
-
private
|
|
7
|
-
|
|
8
|
-
def run_agent_plain(name)
|
|
9
|
-
streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
|
|
10
|
-
agent_loop(name, streamer, stderr: @stderr).run
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def run_agent_tui(name)
|
|
14
|
-
ctx = tui_context(name)
|
|
15
|
-
session = Tui::Session.new(**ctx[:session])
|
|
16
|
-
session.run { ctx[:loop].run }
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def tui_context(name)
|
|
20
|
-
parts = tui_parts(name)
|
|
21
|
-
{
|
|
22
|
-
loop: tui_loop(name, parts),
|
|
23
|
-
session: tui_session_args(name, **parts)
|
|
24
|
-
}
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def tui_parts(name)
|
|
28
|
-
clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
29
|
-
handle = assignee_handle(name)
|
|
30
|
-
log = Tui::LogBuffer.new
|
|
31
|
-
{
|
|
32
|
-
clock: clock, handle: handle, log: log,
|
|
33
|
-
metrics: tui_metrics(name, handle, clock, log),
|
|
34
|
-
agent: agent_for(name, OutputStreamer.new(log: log)),
|
|
35
|
-
provider: provider_for(handle),
|
|
36
|
-
pause_gate: Control::PauseGate.new
|
|
37
|
-
}
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def tui_loop(name, parts)
|
|
41
|
-
agent_loop(name, nil, agent: parts[:agent], provider: parts[:provider],
|
|
42
|
-
handle: parts[:handle], stderr: parts[:log],
|
|
43
|
-
metrics: parts[:metrics], pause_gate: parts[:pause_gate])
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def tui_metrics(name, handle, clock, log)
|
|
47
|
-
Tui::Metrics.new(name: name, handle: handle, clock: clock,
|
|
48
|
-
on_run_start: ->(_label) { log.divider })
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def tui_session_args(name, **parts)
|
|
52
|
-
{
|
|
53
|
-
name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:metrics],
|
|
54
|
-
terminal: Tui::Terminal.new(stream: @stdout),
|
|
55
|
-
input: Tui::Input.new(stdin: @stdin),
|
|
56
|
-
refresh: -> { parts[:provider].call },
|
|
57
|
-
wait_seconds: wait_seconds, clock: parts[:clock],
|
|
58
|
-
pause_gate: parts[:pause_gate], runner: -> { parts[:agent].runner }
|
|
59
|
-
}
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def agent_for(name, streamer)
|
|
63
|
-
Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
|
|
64
|
-
command: pi_command)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def provider_for(handle)
|
|
68
|
-
BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
|
|
69
|
-
env: ENV.to_h.merge(@env))
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def agent_loop(name, streamer, **opts)
|
|
73
|
-
handle = opts[:handle] || assignee_handle(name)
|
|
74
|
-
agent = opts[:agent] || agent_for(name, streamer)
|
|
75
|
-
provider = opts[:provider] || provider_for(handle)
|
|
76
|
-
AgentLoop.new(name: name, handle: handle, agent: agent,
|
|
77
|
-
task_provider: -> { provider.call },
|
|
78
|
-
wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
|
|
79
|
-
metrics: opts[:metrics], pause_gate: opts[:pause_gate],
|
|
80
|
-
watcher: backlog_watcher)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def backlog_watcher
|
|
84
|
-
Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Letsdo
|
|
4
|
-
# Pi JSON event-stream handlers used by PiRunner.
|
|
5
|
-
module PiRunnerEvents
|
|
6
|
-
private
|
|
7
|
-
|
|
8
|
-
def read_pi_stream(out_r)
|
|
9
|
-
out_r.each_line { |line| handle_line(line) }
|
|
10
|
-
debug('stream: EOF')
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def handle_line(line)
|
|
14
|
-
line = line.strip
|
|
15
|
-
return if line.empty?
|
|
16
|
-
|
|
17
|
-
event = parse_event(line)
|
|
18
|
-
dispatch_event(event) if event
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def dispatch_event(event)
|
|
22
|
-
case event['type']
|
|
23
|
-
when 'message_update' then handle_message_update(event)
|
|
24
|
-
when 'tool_execution_start' then handle_tool_start(event)
|
|
25
|
-
when 'tool_execution_end' then handle_tool_execution_end(event)
|
|
26
|
-
when 'agent_end' then flush_pending_tools
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def handle_tool_start(event)
|
|
31
|
-
@pending_tools.delete(event['toolCallId'])
|
|
32
|
-
@streamer.tool_start(event['toolName'] || 'tool', args: event['args'])
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def handle_message_update(event)
|
|
36
|
-
payload = event['assistantMessageEvent']
|
|
37
|
-
return unless payload
|
|
38
|
-
|
|
39
|
-
handle_text_delta(payload) || remember_toolcall(event, payload)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def handle_text_delta(payload)
|
|
43
|
-
return unless payload['type'] == 'text_delta'
|
|
44
|
-
|
|
45
|
-
delta = payload['delta']
|
|
46
|
-
@streamer.text_delta(delta) if delta && !delta.empty?
|
|
47
|
-
true
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def remember_toolcall(event, payload)
|
|
51
|
-
return unless payload['type'] == 'toolcall_start'
|
|
52
|
-
|
|
53
|
-
id = event['id'] || payload['id']
|
|
54
|
-
name = event['toolName'] || payload['toolName'] || 'tool'
|
|
55
|
-
@pending_tools[id] = name unless id.nil?
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def handle_tool_execution_end(event)
|
|
59
|
-
name = event['toolName'] || 'tool'
|
|
60
|
-
error = event['isError'] == true
|
|
61
|
-
@streamer.tool_result(name, error: error)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def flush_pending_tools
|
|
65
|
-
@pending_tools.each_value { |name| @streamer.tool_start(name) }
|
|
66
|
-
@pending_tools.clear
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def parse_event(line)
|
|
70
|
-
JSON.parse(line)
|
|
71
|
-
rescue JSON::ParserError
|
|
72
|
-
nil
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Letsdo
|
|
4
|
-
# Process-group control for a running pi child.
|
|
5
|
-
module PiRunnerProcess
|
|
6
|
-
def terminate(signal: 'TERM', grace: 3.0, tick: 0.05)
|
|
7
|
-
pid = @pid
|
|
8
|
-
return true unless pid
|
|
9
|
-
|
|
10
|
-
send_signal('CONT', pid)
|
|
11
|
-
send_signal(signal, pid)
|
|
12
|
-
wait_for_exit(pid, grace, tick)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
def wait_for_exit(pid, grace, tick)
|
|
18
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
|
|
19
|
-
loop do
|
|
20
|
-
status = wait_status(pid, nonblock: true)
|
|
21
|
-
return status if status
|
|
22
|
-
break unless alive?(pid)
|
|
23
|
-
return kill_and_reap(pid, tick) if overdue_deadline?(deadline)
|
|
24
|
-
|
|
25
|
-
sleep(tick)
|
|
26
|
-
end
|
|
27
|
-
true
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def overdue_deadline?(deadline)
|
|
31
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def kill_and_reap(pid, tick)
|
|
35
|
-
send_signal('KILL', pid)
|
|
36
|
-
sleep(tick)
|
|
37
|
-
wait_status(pid, nonblock: true) || true
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def wait_status(pid, nonblock: false)
|
|
41
|
-
_, status = Process.wait2(pid, nonblock ? Process::WNOHANG : 0)
|
|
42
|
-
@reaped_status = status if status
|
|
43
|
-
status
|
|
44
|
-
rescue Errno::ECHILD
|
|
45
|
-
@reaped_status
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def send_signal(signal, pid)
|
|
49
|
-
Process.kill(signal, -pid)
|
|
50
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
51
|
-
nil
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def alive?(pid)
|
|
55
|
-
Process.kill(0, pid)
|
|
56
|
-
true
|
|
57
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
58
|
-
false
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def exit_code(status)
|
|
62
|
-
return 1 if status.nil?
|
|
63
|
-
return status.exitstatus if status.exitstatus
|
|
64
|
-
|
|
65
|
-
status.termsig ? 128 + status.termsig : 1
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
data/lib/letsdo/pi_runner.rb
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'json'
|
|
4
|
-
|
|
5
|
-
module Letsdo
|
|
6
|
-
# Runs pi in --mode json and hands events to the output streamer.
|
|
7
|
-
class PiRunner
|
|
8
|
-
COMMAND = 'pi'
|
|
9
|
-
MODE = 'json'
|
|
10
|
-
MESSAGE_UPDATE = 'message_update'
|
|
11
|
-
TOOL_EXECUTION_START = 'tool_execution_start'
|
|
12
|
-
TOOL_EXECUTION_END = 'tool_execution_end'
|
|
13
|
-
AGENT_END = 'agent_end'
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
require_relative 'pi_runner/events'
|
|
18
|
-
require_relative 'pi_runner/process'
|
|
19
|
-
|
|
20
|
-
module Letsdo
|
|
21
|
-
# Pi process spawn, stream drain, and pause/resume signals.
|
|
22
|
-
class PiRunner
|
|
23
|
-
include PiRunnerEvents
|
|
24
|
-
include PiRunnerProcess
|
|
25
|
-
|
|
26
|
-
def initialize(prompt:, streamer:, **opts)
|
|
27
|
-
@prompt = prompt
|
|
28
|
-
@streamer = streamer
|
|
29
|
-
@flags = opts.fetch(:flags, [])
|
|
30
|
-
@command = opts.fetch(:command, COMMAND)
|
|
31
|
-
@pending_tools = {}
|
|
32
|
-
@reaped_status = nil
|
|
33
|
-
@debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def run
|
|
37
|
-
out_r = spawn_pi
|
|
38
|
-
drain_stream(out_r)
|
|
39
|
-
finish_run
|
|
40
|
-
ensure
|
|
41
|
-
@pid = nil
|
|
42
|
-
@reaped_status = nil
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def debug(message)
|
|
46
|
-
warn("[letsdo] pi: #{message}") if @debug
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def terminate_now
|
|
50
|
-
pid = @pid
|
|
51
|
-
send_signal('TERM', pid) if pid
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def pause
|
|
55
|
-
pid = @pid
|
|
56
|
-
send_signal('STOP', pid) if pid
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def resume
|
|
60
|
-
pid = @pid
|
|
61
|
-
send_signal('CONT', pid) if pid
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
def spawn_pi
|
|
67
|
-
cmd = [@command, '--mode', MODE, *@flags, @prompt]
|
|
68
|
-
out_r, out_w = IO.pipe
|
|
69
|
-
@pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
|
|
70
|
-
out_w.close
|
|
71
|
-
debug("spawned pid=#{@pid} (own group)")
|
|
72
|
-
out_r
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def drain_stream(out_r)
|
|
76
|
-
read_pi_stream(out_r)
|
|
77
|
-
rescue Letsdo::Stopped
|
|
78
|
-
debug('stopped by signal, terminating pi')
|
|
79
|
-
terminate
|
|
80
|
-
wait_status(@pid)
|
|
81
|
-
@streamer.finish
|
|
82
|
-
raise
|
|
83
|
-
ensure
|
|
84
|
-
close_pipe(out_r)
|
|
85
|
-
flush_pending_tools
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def close_pipe(out_r)
|
|
89
|
-
out_r.close
|
|
90
|
-
rescue IOError
|
|
91
|
-
nil
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def finish_run
|
|
95
|
-
status = wait_status(@pid)
|
|
96
|
-
@streamer.finish
|
|
97
|
-
debug("exit status=#{status.inspect} code=#{exit_code(status)}")
|
|
98
|
-
exit_code(status)
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|