agent-council 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.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module AgentCouncil
7
+ # Reads and formats the models available to pi.
8
+ module PiModels
9
+ FIELD_NAMES = {
10
+ provider: 'provider',
11
+ id: 'id',
12
+ name: 'name',
13
+ api: 'api',
14
+ reasoning: 'reasoning',
15
+ context_window: 'contextWindow',
16
+ max_tokens: 'maxTokens',
17
+ }.freeze
18
+
19
+ Model = Data.define(*FIELD_NAMES.keys) do
20
+ def self.from_hash(raw)
21
+ missing = FIELD_NAMES.values - raw.keys
22
+ raise ArgumentError, "missing model field(s): #{missing.join(', ')}" unless missing.empty?
23
+
24
+ new(**FIELD_NAMES.to_h { |member, field| [member, raw[field]] })
25
+ end
26
+
27
+ # Keep pi's JSON field names in --json output while exposing Ruby-style
28
+ # accessors to callers.
29
+ def to_h
30
+ FIELD_NAMES.to_h { |member, field| [field, public_send(member)] }
31
+ end
32
+ end
33
+
34
+ # Raised for failures talking to pi (missing executable, early exit, RPC error).
35
+ class Error < RuntimeError; end
36
+
37
+ RPC_GET_MODELS = { 'type' => 'get_available_models' }.freeze
38
+ RPC_REQUEST_ID = 'list-models'
39
+
40
+ module_function def fetch_models
41
+ Open3.popen3('pi', '--mode', 'rpc', '--no-session') do |stdin, stdout, stderr, wait_thr|
42
+ # pi's output is UTF-8, whatever the locale.
43
+ [stdout, stderr].each { |io| io.set_encoding(Encoding::UTF_8) }
44
+ # Drain stderr concurrently so a chatty pi can't block on a full pipe,
45
+ # and so we can report it if pi fails.
46
+ # An I/O failure while reading is treated as "no diagnostics" so it
47
+ # can't escape as something other than PiModels::Error.
48
+ stderr_reader = Thread.new do
49
+ stderr.read
50
+ rescue IOError, SystemCallError
51
+ ''
52
+ end
53
+ begin
54
+ send_rpc_command(stdin, RPC_GET_MODELS.merge('id' => RPC_REQUEST_ID))
55
+ rescue Errno::EPIPE, IOError
56
+ # pi exited (or closed stdin) before we could send the request; fall
57
+ # through so the exit-status/diagnostics check below explains why.
58
+ end
59
+
60
+ # No chomp: true: JRuby rejects it on a blockless each_line, and
61
+ # JSON.parse ignores the newline anyway.
62
+ events = stdout.each_line.lazy.filter_map { |line| parse_event(line) }
63
+ response = events.detect { |event| event['id'] == RPC_REQUEST_ID && event['type'] == 'response' }
64
+ return event_to_models(response) if response
65
+
66
+ diagnostics = stderr_reader.value.to_s.strip
67
+ message = "pi exited (#{describe_status(wait_thr.value)}) before responding"
68
+ message += ": #{diagnostics}" unless diagnostics.empty?
69
+ raise Error, message
70
+ ensure
71
+ stderr_reader&.kill
72
+ end
73
+ rescue Errno::ENOENT
74
+ raise Error, 'pi executable not found; is `pi` installed and on your PATH?'
75
+ rescue SystemCallError => e
76
+ # Covers EACCES (not executable) and other spawn-time failures that
77
+ # Errno::ENOENT doesn't catch.
78
+ raise Error, "pi could not be started: #{e.message}"
79
+ end
80
+
81
+ # Describes a Process::Status for diagnostics, covering both normal exits
82
+ # and termination by signal (where #exitstatus is nil).
83
+ module_function def describe_status(status)
84
+ return "signal #{status.termsig}" if status.signaled?
85
+
86
+ status.exitstatus.to_s
87
+ end
88
+
89
+ # Returns the parsed event, or nil for lines that aren't JSON objects
90
+ # (blank lines, stray non-protocol output).
91
+ module_function def parse_event(line)
92
+ event = JSON.parse(line)
93
+ event if event.is_a?(Hash)
94
+ rescue JSON::ParserError
95
+ nil
96
+ end
97
+
98
+ module_function def send_rpc_command(stdin, command)
99
+ stdin.sync = true
100
+ stdin.puts(JSON.generate(command))
101
+ stdin.close
102
+ end
103
+
104
+ module_function def event_to_models(event)
105
+ raise Error, "pi error: #{event['error']}" unless event['success']
106
+
107
+ raw_models = event.dig('data', 'models')
108
+ raise Error, "pi response missing data.models: #{event.inspect}" unless raw_models.is_a?(Array)
109
+
110
+ raw_models.map do |raw_model|
111
+ Model.from_hash(raw_model)
112
+ rescue ArgumentError => e
113
+ raise Error, "pi returned a malformed model: #{e.message}"
114
+ end
115
+ end
116
+
117
+ module_function def help_text
118
+ <<~HELP
119
+ Usage: pi-models [options]
120
+
121
+ List provider/model pairs available to `pi`.
122
+
123
+ Options:
124
+ -t, --table Formatted table (default)
125
+ -j, --json Pretty-printed JSON array of model hashes, with pi's fields:
126
+ #{FIELD_NAMES.values.join(', ')}
127
+ -i, --ids One "provider/id" per line (shell-friendly)
128
+ -h, --help Show this help and exit
129
+
130
+ Examples:
131
+ pi-models
132
+ pi-models --json | jq '.[0]'
133
+ pi-models --ids | head -5
134
+ pi-models --table # explicit default
135
+ HELP
136
+ end
137
+
138
+ module_function def format_as_table(models)
139
+ return "No models configured. Run `pi` interactively and add a provider first.\n" if models.empty?
140
+
141
+ columns = [
142
+ { label: 'PROVIDER', value: ->(model) { model.provider.to_s }, align: :left },
143
+ { label: 'MODEL ID', value: ->(model) { model.id.to_s }, align: :left },
144
+ { label: 'API', value: ->(model) { model.api.to_s }, align: :left },
145
+ { label: 'REASONING', value: ->(model) { model.reasoning ? 'yes' : 'no' }, align: :left },
146
+ { label: 'CONTEXT', value: ->(model) { model.context_window.to_s }, align: :right },
147
+ { label: 'MAX TOKENS', value: ->(model) { model.max_tokens.to_s }, align: :right },
148
+ { label: 'NAME', value: ->(model) { model.name.to_s }, align: :left },
149
+ ]
150
+
151
+ widths = columns.map do |column|
152
+ max_value_length = models.map { |model| column[:value].(model).length }.max
153
+ [max_value_length, column[:label].length].max
154
+ end
155
+
156
+ last_index = columns.length - 1
157
+ format_string = columns.each_with_index.map do |column, index|
158
+ # Don't pad the final left-aligned column; it would only add trailing whitespace.
159
+ next '%s' if index == last_index && column[:align] == :left
160
+
161
+ align_sign = column[:align] == :right ? '' : '-'
162
+ "%#{align_sign}#{widths[index]}s"
163
+ end.join(' ') + "\n"
164
+
165
+ separator = widths.map { |width| '-' * width }.join(' ') + "\n"
166
+ header = format(format_string, *columns.map { |column| column[:label] })
167
+ body_lines = models.map do |model|
168
+ format(format_string, *columns.map do |column|
169
+ column[:value].(model)
170
+ end)
171
+ end
172
+
173
+ separator + header + separator + body_lines.join + separator
174
+ end
175
+
176
+ module_function def format_as_json(models)
177
+ JSON.pretty_generate(models.map(&:to_h)) + "\n"
178
+ end
179
+
180
+ # Empty for an empty list, so shell loops and `wc -l` see no models.
181
+ module_function def format_as_ids(models)
182
+ models.map { |model| "#{model.provider}/#{model.id}\n" }.join
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kept free of other project requires so both executables can load it:
4
+ # pi-models doesn't load lib/agent_council.rb.
5
+
6
+ require 'rbconfig'
7
+
8
+ module AgentCouncil
9
+ # host_os rather than RUBY_PLATFORM, which is "java" on JRuby whatever the
10
+ # OS, and rather than Gem.win_platform?, so as not to depend on RubyGems.
11
+ module_function def windows?
12
+ /mswin|mingw/.match?(RbConfig::CONFIG['host_os'])
13
+ end
14
+
15
+ # Native Windows is unsupported; WSL works as Linux does. Ruby runs npm's
16
+ # pi.cmd through cmd.exe, which truncates arguments at newlines (breaking
17
+ # the multi-line system prompt), and WorkerPool relies on process groups
18
+ # and SIGTERM.
19
+ module_function def abort_if_native_windows(command = 'agent-council')
20
+ return unless windows?
21
+
22
+ abort <<~MESSAGE
23
+ #{command} does not support native Windows; please run it inside WSL.
24
+ See "Prerequisites" in #{File.expand_path('../../README.md', __dir__)}
25
+ MESSAGE
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ # Report format
2
+
3
+ Your response will be saved as a standalone Markdown report. A metadata
4
+ table (role, agent, query, harness, provider, model, timestamp, duration)
5
+ is prepended to it
6
+ automatically, so:
7
+
8
+ - Do not add a document title or any top-level (`#`) heading.
9
+ - Begin directly with your first `##` section heading.
10
+ - Use Markdown throughout: headings, lists, and fenced code blocks with
11
+ language tags.
12
+ - Keep file paths and line references concrete and exact.
@@ -0,0 +1,72 @@
1
+ # Consolidation task
2
+
3
+ You are consolidating several independent analyses of the same repository.
4
+ Every analyst received the identical prompt (included below as "Original
5
+ prompt"), followed by formatting instructions for its report. Their reports follow under "Analyst reports", each headed "Report
6
+ from" its source label (`#<agent number> <provider> / <model>`), with a
7
+ "Harness:" line below it. Agents that produced no report are listed last,
8
+ under "Agents without a report", each with the reason shown there (its
9
+ failure, when known).
10
+
11
+ Produce a consolidated report in two parts, with exactly these sections in
12
+ this order. The first part is the findings; the second describes the
13
+ consultation itself. Between them, write a line containing exactly
14
+ `=== META ===` and nothing else (once, and nowhere else in the report).
15
+
16
+ ## Findings
17
+
18
+ - Output the findings of each section (consolidated or unique) in a _numbered_ list.
19
+ - For each finding, add explanatory text if it would be helpful to the reader.
20
+
21
+ ### Consensus findings
22
+
23
+ Issues identified independently by two or more analysts.
24
+
25
+ For each, list the sources and briefly note any differences in emphasis.
26
+
27
+ ### Unique findings
28
+
29
+ Issues raised by exactly one analyst, attributed to its source.
30
+
31
+ - Assess whether each is plausible.
32
+ - Group findings by agent; for each group:
33
+ - output a heading with the agent number, harness, provider, and model
34
+ name (the harness is shown under each report's heading)
35
+
36
+ ## Contradictions
37
+
38
+ Points where analysts disagree, with each position attributed.
39
+
40
+ ## Overall assessment
41
+
42
+ The most important findings overall and your confidence in each. If you can
43
+ verify a claim against the repository, do so (read-only) and state the
44
+ result.
45
+
46
+ === META ===
47
+
48
+ ## Agent Consultants
49
+
50
+ A list of provider/model agents consulted. For each agent listed under
51
+ "Agents without a report", say it produced no report and give the reason
52
+ shown there; do not infer failures any other way. If a report shows its
53
+ agent could not properly do the task, say so too.
54
+
55
+ ## Agent performance
56
+
57
+ A brief review of each agent that produced a report: how deep its research
58
+ went (e.g. did it read code, run commands, verify claims) and how much it
59
+ added to the review (unique or confirmed findings vs. noise or errors).
60
+ For each agent, print a header line with agent number, duration, harness, provider,
61
+ and model. Then print a single text string below it with the performance information.
62
+
63
+ Rules:
64
+ - Begin directly with the "## Findings" section; do not add a
65
+ document title or top-level heading.
66
+ - Attribute every claim to its source model(s); never drop attribution.
67
+ - Explain findings, don't just list them: when listing findings, unanimous
68
+ or otherwise, add enough explanation that the reader can easily
69
+ understand what the issue is. Avoid limiting the text to single-phrase
70
+ attributions.
71
+ - Do not invent findings that appear in no report.
72
+ - Deduplicate, but do not dilute: keep concrete file and line references.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCouncil
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module AgentCouncil
6
+ # Worker threads and agent processes for a run, and what Ctrl-C (or
7
+ # SIGTERM, SIGHUP, or SIGQUIT) does to them. main's rescue Interrupt calls
8
+ # into this class for everything but its own message.
9
+ #
10
+ # Each agent process runs in its own process group (see #capture3), so a
11
+ # terminal's Ctrl-C reaches only agent-council, never the agents. Once
12
+ # #trap_signals is installed, the first INT, TERM, HUP, or QUIT (not on
13
+ # JRuby; see TERMINATING_SIGNALS) raises Interrupt in the main thread,
14
+ # whose rescue calls #shutdown: it sends every agent SIGTERM (pi then also
15
+ # stops any commands its bash tool started, which run detached and so would
16
+ # survive the agent), waits up to the grace period for the workers to
17
+ # finish, and SIGKILLs agents still running. A second trapped signal other
18
+ # than HUP SIGKILLs them at once. Any HUP after the first signal is
19
+ # ignored: a closing terminal can deliver two at once (one forwarded by the
20
+ # shell, one from the kernel), which must not skip the graceful stop. A
21
+ # signal agent-council was started ignoring (e.g. HUP under nohup) stays
22
+ # ignored. Either way, agent-council then dies by the signal (see
23
+ # .exit_by_signal).
24
+ #
25
+ # Agents run in worker threads, never the main thread, so the main thread
26
+ # is always free to take the Interrupt. Workers report completion through
27
+ # a queue and only the main thread prints, so nothing more is printed once
28
+ # the Interrupt has been taken.
29
+ class WorkerPool
30
+ # Not QUIT on JRuby: the JVM reserves it for thread dumps, so trapping
31
+ # it only prints a warning.
32
+ TERMINATING_SIGNALS = (RUBY_ENGINE == 'jruby' ? %w[INT TERM HUP] : %w[INT TERM HUP QUIT]).freeze
33
+
34
+ # Each agent leads a new process group, so terminal signals skip it and
35
+ # #signal_child reaches the processes in that group (not ones a tool
36
+ # detached into a group of their own).
37
+ SPAWN_OPTIONS = { pgroup: true }.freeze
38
+
39
+ # Ends the process as the signal would have, so the caller (e.g. a shell
40
+ # running agent-council in a loop) sees it was killed by the signal, not
41
+ # that it handled it. QUIT exits 131 instead, rather than dumping core.
42
+ def self.exit_by_signal(name)
43
+ unless name == 'QUIT'
44
+ trap(name, 'SYSTEM_DEFAULT')
45
+ Process.kill(name, Process.pid)
46
+ sleep 1 # in case delivery isn't immediate
47
+ end
48
+ exit!(128 + Signal.list.fetch(name))
49
+ end
50
+
51
+ def initialize(grace:)
52
+ @grace = grace
53
+ @threads = []
54
+ @finished = Queue.new # indexes of threads as they finish
55
+ @uncollected = 0 # threads not yet #collect'ed
56
+ @children = {} # agent pids (each also its process group id)
57
+ @children_lock = Mutex.new
58
+ @stop_signal = nil # set once shutdown starts signalling agents
59
+ @first_signal = nil # the signal that started the shutdown
60
+ # Taken by the first signal. A Queue, because JRuby can run two traps
61
+ # at once (so checking @first_signal could let both through), and
62
+ # MRI forbids Mutex#lock in a trap but not Queue#pop.
63
+ @first_signal_token = Queue.new << true
64
+ end
65
+
66
+ # Installs the traps described above. The Interrupt is raised with
67
+ # Thread#raise, so it respects the deferral in #start.
68
+ def trap_signals
69
+ TERMINATING_SIGNALS.each do |name|
70
+ previous = trap(name) do
71
+ if first_signal?
72
+ @first_signal = name
73
+ Thread.main.raise(Interrupt)
74
+ elsif name != 'HUP'
75
+ kill_children_and_exit(name)
76
+ end
77
+ end
78
+ trap(name, previous) if previous == 'IGNORE'
79
+ end
80
+ end
81
+
82
+ # Runs the block in a new worker thread. The Interrupt is deferred while
83
+ # the thread is started and recorded, so #shutdown sees every started
84
+ # thread. The thread would inherit that deferral, so it's undone inside.
85
+ def start(&block)
86
+ Thread.handle_interrupt(Interrupt => :never) do
87
+ index = @threads.size
88
+ @uncollected += 1
89
+ @threads << Thread.new do
90
+ # A worker's exception is re-raised by #collect; don't print it
91
+ # twice.
92
+ Thread.current.report_on_exception = false
93
+ Thread.handle_interrupt(Interrupt => :immediate, &block)
94
+ ensure
95
+ @finished << index
96
+ end
97
+ end
98
+ end
99
+
100
+ # Waits for the threads started since the last call, yielding each
101
+ # one's value as it finishes. Returns their values in start order.
102
+ # Re-raises if a worker raised.
103
+ def collect
104
+ count = @uncollected
105
+ @uncollected = 0
106
+ finished = Array.new(count) do
107
+ index = @finished.pop
108
+ [index, @threads[index].value.tap { |value| yield value if block_given? }]
109
+ end
110
+ finished.sort_by(&:first).map(&:last)
111
+ end
112
+
113
+ # Open3.capture3, but with the agent in its own process group and
114
+ # tracked for #shutdown while it runs, and its output read as UTF-8
115
+ # whatever the locale. An agent spawned after shutdown has started
116
+ # signalling gets the same signal at once.
117
+ def capture3(env, *argv, stdin_data:)
118
+ Open3.popen3(env, *argv, **SPAWN_OPTIONS) do |stdin, stdout, stderr, wait_thr|
119
+ [stdout, stderr].each { |io| io.set_encoding(Encoding::UTF_8) }
120
+ pid = wait_thr.pid
121
+ begin
122
+ # Read after registering; signal_children sets it before reading
123
+ # @children, so an agent is signalled by one or the other.
124
+ signal = @children_lock.synchronize do
125
+ @children[pid] = true
126
+ @stop_signal
127
+ end
128
+ signal_child(pid, signal) if signal
129
+ out_reader = Thread.new { stdout.read }
130
+ err_reader = Thread.new { stderr.read }
131
+ begin
132
+ stdin.write(stdin_data)
133
+ rescue Errno::EPIPE
134
+ nil # the agent exited without reading its input
135
+ end
136
+ stdin.close
137
+ [out_reader.value, err_reader.value, wait_thr.value]
138
+ ensure
139
+ @children_lock.synchronize { @children.delete(pid) }
140
+ end
141
+ end
142
+ end
143
+
144
+ # Called from main's rescue Interrupt: yields so the caller can report
145
+ # it, then stops the agents as described above. Returns once the
146
+ # workers are done or the agents have been killed; the caller then
147
+ # calls #exit_by_signal. A second signal meanwhile exits from the trap.
148
+ def shutdown
149
+ flush_stdout # dying by a signal skips this
150
+ yield if block_given?
151
+ signal_children('TERM')
152
+ return if all_joined_by?(monotonic_now + @grace)
153
+
154
+ signal_children('KILL')
155
+ all_joined_by?(monotonic_now + 1) # let killed agents' workers clean up
156
+ end
157
+
158
+ # .exit_by_signal for the signal that stopped the run.
159
+ def exit_by_signal
160
+ self.class.exit_by_signal(@first_signal || 'INT')
161
+ end
162
+
163
+ # name: the signal that called it, for when it arrived together with the
164
+ # first one, whose trap may not have set @first_signal yet.
165
+ private def kill_children_and_exit(name)
166
+ signal_children('KILL')
167
+ flush_stdout
168
+ self.class.exit_by_signal(@first_signal || name)
169
+ end
170
+
171
+ private def first_signal?
172
+ @first_signal_token.pop(true)
173
+ rescue ThreadError # already taken
174
+ false
175
+ end
176
+
177
+ # Signals every running agent's process group. Callable from a trap.
178
+ private def signal_children(signal)
179
+ @stop_signal = signal
180
+ children_snapshot.each { |pid| signal_child(pid, signal) }
181
+ end
182
+
183
+ # A snapshot, so a concurrent spawn can't modify the hash mid-iteration.
184
+ # MRI forbids Mutex#lock in a trap, but its GVL makes Hash#keys atomic.
185
+ # JRuby has no GVL, but its traps run in ordinary threads, which may lock.
186
+ private def children_snapshot
187
+ return @children.keys unless RUBY_ENGINE == 'jruby'
188
+
189
+ @children_lock.synchronize { @children.keys }
190
+ end
191
+
192
+ private def signal_child(pid, signal)
193
+ Process.kill(signal, -pid)
194
+ rescue SystemCallError
195
+ nil # already gone
196
+ end
197
+
198
+ # A hung-up terminal can make writing fail; that mustn't stop shutdown.
199
+ private def flush_stdout
200
+ $stdout.flush
201
+ rescue IOError, SystemCallError
202
+ nil
203
+ end
204
+
205
+ # Joins every thread until the deadline; true if all finished. A worker
206
+ # that raised has finished (#collect reports its exception).
207
+ private def all_joined_by?(deadline)
208
+ @threads.map do |thread|
209
+ thread.join([deadline - monotonic_now, 0].max)
210
+ rescue
211
+ true
212
+ end.all?
213
+ end
214
+
215
+ private def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
216
+ end
217
+ end