openlanes 3.0.0 → 3.0.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/agents/c3po-operator.md +58 -0
- data/lib/openlanes/adapters/base.rb +7 -0
- data/lib/openlanes/adapters/claude.rb +21 -2
- data/lib/openlanes/adapters.rb +19 -2
- data/lib/openlanes/cli/run/run.rb +47 -15
- data/lib/openlanes/cli/start/start.rb +1 -14
- data/lib/openlanes/cli/web/web.rb +11 -1
- data/lib/openlanes/engine/dispatcher.rb +62 -7
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/progress_log.rb +50 -9
- data/lib/openlanes/execution/prompt.rb +13 -0
- data/lib/openlanes/lifecycle/creator.rb +24 -0
- data/lib/openlanes/live/chat.rb +616 -0
- data/lib/openlanes/live/commands.rb +4 -1
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot.rb +256 -0
- data/lib/openlanes/live/keys.rb +58 -0
- data/lib/openlanes/live/memory_store.rb +24 -0
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/session.rb +7 -1
- data/lib/openlanes/live/stations.rb +9 -5
- data/lib/openlanes/live/store.rb +18 -0
- data/lib/openlanes/live/subscriber.rb +73 -0
- data/lib/openlanes/live.rb +5 -0
- data/lib/openlanes/presentation/reporter.rb +2 -2
- data/lib/openlanes/support/ui.rb +75 -1
- data/lib/openlanes/vcs/publisher.rb +62 -0
- data/lib/openlanes/vcs/worktree.rb +44 -9
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/app.rb +100 -1
- data/lib/openlanes/web/context.rb +20 -2
- data/lib/openlanes/web/demo.rb +10 -0
- data/lib/openlanes/web/events.rb +51 -2
- data/lib/openlanes/web/launcher.rb +1 -1
- data/lib/openlanes/web/server.rb +2 -0
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +13 -0
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-BdR34cXC.js → index-6MZkmh3k.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-B5CKJPm-.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-BF6QYroq.js} +2 -2
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-BFPI-zXI.js} +2 -2
- data/web/dist/assets/{index-CI74IkWP.js → index-BYJCWlk0.js} +49 -49
- data/web/dist/assets/{index-popk9wbW.js → index-C2jwoWVV.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-C3zc4gFe.js} +2 -2
- data/web/dist/assets/index-CjInBi5X.css +1 -0
- data/web/dist/assets/{index-2xPC-eHt.js → index-CnJfB0l2.js} +2 -2
- data/web/dist/assets/{index-P3vSkiU-.js → index-CnvkZu5Y.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-DH0gDlee.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-DXgeajd6.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-DaSjQ-H4.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-DbueqOK5.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-Dbxvk-EC.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-DtA46i32.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-IqD8mBos.js} +2 -2
- data/web/dist/assets/{index-Byyxbh_z.js → index-bpsFIpXE.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-pvJi-JJO.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- metadata +31 -23
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
class Copilot
|
|
6
|
+
# What c3po-operator is told with each message: the board as it stands,
|
|
7
|
+
# where to read further, and the person's words.
|
|
8
|
+
#
|
|
9
|
+
# The board lives in Redis, which the agent cannot read: its Bash runs
|
|
10
|
+
# only `openlanes` commands that read state, and none of them prints the
|
|
11
|
+
# live board. So the server reads the snapshot and writes it in here,
|
|
12
|
+
# fresh with every message, since the run moves between two questions.
|
|
13
|
+
# It is written as lines rather than the snapshot's JSON because the
|
|
14
|
+
# agent pays for every token of it on every message.
|
|
15
|
+
class Brief
|
|
16
|
+
# Recent log lines carried.
|
|
17
|
+
LOG_LINES = 20
|
|
18
|
+
|
|
19
|
+
# Characters of a question's body carried; the agent reads the rest
|
|
20
|
+
# from blocked.md when it needs it.
|
|
21
|
+
BODY = 400
|
|
22
|
+
|
|
23
|
+
# @param text [String] what the person typed
|
|
24
|
+
# @param board [Hash, nil] this node's `Snapshot` as parsed JSON
|
|
25
|
+
# @param others [Array<Hash>] other machines' snapshots
|
|
26
|
+
# @param plans_dir [String]
|
|
27
|
+
# @param trace_dir [String]
|
|
28
|
+
def initialize(text:, board:, plans_dir:, trace_dir:, others: [])
|
|
29
|
+
@text = text
|
|
30
|
+
@board = board
|
|
31
|
+
@others = others
|
|
32
|
+
@plans_dir = plans_dir
|
|
33
|
+
@trace_dir = trace_dir
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String]
|
|
37
|
+
def to_s
|
|
38
|
+
[board, others, where, "## The person says\n\n#{@text}"].compact.join("\n\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @return [String]
|
|
44
|
+
def board
|
|
45
|
+
return "## The board\n\nNo engine is publishing a board for this checkout: no run is going, or it has ended." unless @board
|
|
46
|
+
|
|
47
|
+
run = @board["run"] || {}
|
|
48
|
+
lines = ["## The board at #{@board["at"]}",
|
|
49
|
+
"",
|
|
50
|
+
"Run #{run["status"]}, #{run["agents_working"].to_i} agents working, ↑ #{run["up"].to_i} ↓ #{run["down"].to_i} tokens, " \
|
|
51
|
+
"$#{format("%.2f", run["cost"].to_f)}."]
|
|
52
|
+
plans = Array(@board["plans"])
|
|
53
|
+
lines << (plans.empty? ? "No plans on this machine's board." : "")
|
|
54
|
+
plans.each { lines.concat(plan(it)) }
|
|
55
|
+
backlog = Array(@board["backlog"])
|
|
56
|
+
lines << "Backlog: #{backlog.map { "#{it["ordinal"]} #{it["title"]} (#{it.dig("status", "label")})" }.join("; ")}" unless backlog.empty?
|
|
57
|
+
lines.concat(log)
|
|
58
|
+
lines.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param plan [Hash]
|
|
62
|
+
# @return [Array<String>]
|
|
63
|
+
def plan(plan)
|
|
64
|
+
lines = ["- #{plan["ordinal"]} #{plan["title"]}: #{plan.dig("status", "emoji")} #{plan.dig("status", "label")}, " \
|
|
65
|
+
"#{plan["lane"]} lane, #{minutes(plan["elapsed"])} worked, $#{format("%.2f", plan["cost"].to_f)}" \
|
|
66
|
+
"#{", PR ##{plan.dig("pr", "number")} #{plan.dig("pr", "state")}" if plan["pr"]}"]
|
|
67
|
+
Array(plan["cars"]).each { lines << car(it) }
|
|
68
|
+
Array(plan["questions"]).each { lines << question(it) }
|
|
69
|
+
if (stop = plan["interrupted"])
|
|
70
|
+
lines << " - interrupted: #{stop["agent"]} at #{stop["at"]}: #{stop["note"]}"
|
|
71
|
+
end
|
|
72
|
+
lines
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param car [Hash]
|
|
76
|
+
# @return [String]
|
|
77
|
+
def car(car)
|
|
78
|
+
" - car #{car["key"]}: #{car["state"]}#{", #{car["phase"]}" if car["phase"]}, round #{car["round"]}/#{car["rounds"]}, " \
|
|
79
|
+
"#{car["model"]}, ↑ #{car["up"].to_i} ↓ #{car["down"].to_i}, #{minutes(car["elapsed"])} elapsed" \
|
|
80
|
+
"#{", #{minutes(car["remaining"])} left" if car["remaining"]}#{", held" if car["held"]}: #{car["message"].to_s.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param question [Hash]
|
|
84
|
+
# @return [String]
|
|
85
|
+
def question(question)
|
|
86
|
+
body = question["body"].to_s.gsub(/\s+/, " ").strip
|
|
87
|
+
body = "#{body[0, BODY]}…" if body.length > BODY
|
|
88
|
+
" - question #{question["id"]} (#{question["owner"]}, asked by #{question["agent"] || "unknown"}" \
|
|
89
|
+
"#{", held" if question["held"]}): #{question["heading"]}: #{body}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Array<String>]
|
|
93
|
+
def log
|
|
94
|
+
lines = Array(@board["log"]).last(LOG_LINES)
|
|
95
|
+
return [] if lines.empty?
|
|
96
|
+
|
|
97
|
+
["", "Recent log, newest last:"] + lines.map { " #{it["at"]} #{it["source"]} #{it["level"]}: #{it["text"]}" }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [String, nil]
|
|
101
|
+
def others
|
|
102
|
+
return nil if @others.empty?
|
|
103
|
+
|
|
104
|
+
machines = @others.map do |snapshot|
|
|
105
|
+
node = snapshot["node"] || {}
|
|
106
|
+
plans = Array(snapshot["plans"]).map { "#{it["ordinal"]} #{it.dig("status", "label")}" }
|
|
107
|
+
"- #{node["user"]}@#{node["host"]}: #{plans.empty? ? "no plans" : plans.join(", ")}"
|
|
108
|
+
end
|
|
109
|
+
"## Other machines (read only)\n\n#{machines.join("\n")}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [String]
|
|
113
|
+
def where
|
|
114
|
+
<<~TEXT.strip
|
|
115
|
+
## Where to read more
|
|
116
|
+
|
|
117
|
+
- Plan folders: #{@plans_dir}. Each holds spec.md, plan.md, blocked.md when blocked, and state.json with its signatures and mailbox.
|
|
118
|
+
- Agents' transcripts: #{@trace_dir}, one NDJSON file per invocation, named by time, plan and agent.
|
|
119
|
+
TEXT
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param seconds [Numeric, nil]
|
|
123
|
+
# @return [String]
|
|
124
|
+
def minutes(seconds) = "#{(seconds.to_f / 60).round}m"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
class Copilot
|
|
8
|
+
# How what c3po-operator wrote becomes the turns the page draws.
|
|
9
|
+
#
|
|
10
|
+
# The agent writes prose, and wherever it wants the page to draw a card
|
|
11
|
+
# it writes one fenced block whose info string is `c3po`, holding one
|
|
12
|
+
# JSON object:
|
|
13
|
+
#
|
|
14
|
+
# ```c3po
|
|
15
|
+
# {"card": "confirm", "text": "luke has not moved in 20 minutes.",
|
|
16
|
+
# "command": {"kind": "stop", "plan": "002", "agent": "luke-backend"},
|
|
17
|
+
# "confirm": "Stop luke-backend", "done": "luke-backend was asked to stop"}
|
|
18
|
+
# ```
|
|
19
|
+
#
|
|
20
|
+
# ```c3po
|
|
21
|
+
# {"card": "draft", "question": "002/B1", "draft": "Ninety days.", "text": "A draft:"}
|
|
22
|
+
# ```
|
|
23
|
+
#
|
|
24
|
+
# The agent proposes; this module decides. A card is drawn only when
|
|
25
|
+
# every check passes: the JSON parses, the card is one of the two kinds,
|
|
26
|
+
# a confirm card's command is one of {CONFIRMABLE} and one the command
|
|
27
|
+
# gate itself would take ({Command.parse}, W13), and what it names is on
|
|
28
|
+
# this node's board. Anything else comes back as plain text with the
|
|
29
|
+
# reason, so a card the agent got wrong is something the person reads,
|
|
30
|
+
# never something the page can send. Nothing here sends anything: even a
|
|
31
|
+
# good card only reaches the engine when the person presses its button.
|
|
32
|
+
module Cards
|
|
33
|
+
# A card: a fence opened with exactly `c3po` and closed on a line of its
|
|
34
|
+
# own. An unclosed one does not match, and so stays prose.
|
|
35
|
+
FENCE = /^```c3po[ \t]*\r?\n(.*?)^```[ \t]*$/m
|
|
36
|
+
|
|
37
|
+
# The commands a confirm card may carry: W17's "stop, discard and stop
|
|
38
|
+
# run", the ones that cannot be undone and so wait for a person.
|
|
39
|
+
# Message and hold are left out on purpose: the engine files a
|
|
40
|
+
# `message` in the plan's mailbox as from `human`, and a card the
|
|
41
|
+
# person confirms without reading would put the copilot's words there
|
|
42
|
+
# under the person's name.
|
|
43
|
+
CONFIRMABLE = %w[stop discard stop_all].freeze
|
|
44
|
+
|
|
45
|
+
# Cards one answer may offer. More is a model listing every action it
|
|
46
|
+
# can think of, and a wall of red buttons is not a question.
|
|
47
|
+
MAX = 3
|
|
48
|
+
|
|
49
|
+
# Characters a confirm button's label may hold.
|
|
50
|
+
LABEL = 60
|
|
51
|
+
|
|
52
|
+
# Characters the line a confirm card shows once accepted may hold.
|
|
53
|
+
DONE = 200
|
|
54
|
+
|
|
55
|
+
# Characters one prose turn keeps. A model that pastes a whole
|
|
56
|
+
# transcript back is cut here rather than sent to every open page.
|
|
57
|
+
PROSE = 20_000
|
|
58
|
+
|
|
59
|
+
# A question's id, as the board writes it: `<ordinal>/B<n>`.
|
|
60
|
+
QUESTION = %r{\A[0-9]{3}(?:\.[0-9]{2})?/B[1-9][0-9]*\z}
|
|
61
|
+
|
|
62
|
+
# What an answer with nothing in it reads as.
|
|
63
|
+
SILENT = "c3po-operator finished without saying anything."
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
# @param answer [String, nil] the agent's final message
|
|
68
|
+
# @param id [String] the person's message id; the turns are `<id>.1`, `<id>.2`, …
|
|
69
|
+
# @param usage [Hash, nil] `{model, up, down}`, carried by every turn
|
|
70
|
+
# @param board [Hash, nil] this node's `Snapshot` as parsed JSON; nil
|
|
71
|
+
# when no engine is publishing one, and then no card names anything
|
|
72
|
+
# @return [Array<OpenLanes::Live::Copilot::Turn>]
|
|
73
|
+
def turns(answer, id:, usage: nil, board: nil)
|
|
74
|
+
offered = 0
|
|
75
|
+
said = pieces(answer.to_s).filter_map do |kind, body|
|
|
76
|
+
next prose(body) if kind == :prose
|
|
77
|
+
|
|
78
|
+
offered += 1
|
|
79
|
+
offered > MAX ? refused(body, "an answer offers at most #{MAX} cards") : card(body, board)
|
|
80
|
+
end
|
|
81
|
+
said = [{ kind: "text", text: SILENT }] if said.empty?
|
|
82
|
+
said.each_with_index.map do |piece, index|
|
|
83
|
+
Turn.new(id: "#{id}.#{index + 1}", from: "copilot", usage:, fields: {}, **piece)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The answer cut into prose and card bodies, in the order written.
|
|
88
|
+
#
|
|
89
|
+
# @param answer [String]
|
|
90
|
+
# @return [Array<Array(Symbol, String)>]
|
|
91
|
+
def pieces(answer)
|
|
92
|
+
found = []
|
|
93
|
+
at = 0
|
|
94
|
+
while (match = FENCE.match(answer, at))
|
|
95
|
+
found << [:prose, answer[at...match.begin(0)]] << [:card, match[1]]
|
|
96
|
+
at = match.end(0)
|
|
97
|
+
end
|
|
98
|
+
found << [:prose, answer[at..]]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param body [String]
|
|
102
|
+
# @return [Hash, nil] nil for whitespace between cards
|
|
103
|
+
def prose(body)
|
|
104
|
+
text = body.strip
|
|
105
|
+
return nil if text.empty?
|
|
106
|
+
|
|
107
|
+
{ kind: "text", text: text.length > PROSE ? "#{text[0, PROSE]}…" : text }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @param body [String] the JSON between the fences
|
|
111
|
+
# @param board [Hash, nil]
|
|
112
|
+
# @return [Hash] a card, or the text it degrades to
|
|
113
|
+
def card(body, board)
|
|
114
|
+
data = JSON.parse(body)
|
|
115
|
+
raise Invalid, "a card is one JSON object" unless data.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
data = data.transform_keys(&:to_s)
|
|
118
|
+
case data["card"]
|
|
119
|
+
when "confirm" then confirm(data, board)
|
|
120
|
+
when "draft" then draft(data, board)
|
|
121
|
+
else raise Invalid, "#{data["card"].inspect} is not a card; there are confirm and draft"
|
|
122
|
+
end
|
|
123
|
+
rescue JSON::ParserError
|
|
124
|
+
refused(body, "it is not JSON")
|
|
125
|
+
rescue Invalid, Command::Invalid => e
|
|
126
|
+
refused(body, e.message)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @param data [Hash]
|
|
130
|
+
# @param board [Hash, nil]
|
|
131
|
+
# @return [Hash]
|
|
132
|
+
# @raise [OpenLanes::Live::Copilot::Invalid, OpenLanes::Live::Command::Invalid]
|
|
133
|
+
def confirm(data, board)
|
|
134
|
+
raw = data["command"]
|
|
135
|
+
raise Invalid, "a confirm card needs a command object" unless raw.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
raw = raw.transform_keys(&:to_s)
|
|
138
|
+
kind = raw["kind"].to_s
|
|
139
|
+
raise Invalid, "#{kind.inspect} is not a command c3po-operator may offer; it offers #{CONFIRMABLE.join(", ")}" unless CONFIRMABLE.include?(kind)
|
|
140
|
+
|
|
141
|
+
# The payload is dropped: none of these kinds reads one, and a card
|
|
142
|
+
# carries exactly what its button says and nothing the person cannot see.
|
|
143
|
+
command = Command.parse(raw.slice("kind", "plan", "agent").merge("id" => "card", "payload" => {}))
|
|
144
|
+
on_board!(command, board)
|
|
145
|
+
{ kind: "confirm",
|
|
146
|
+
text: words(data["text"], LIMIT) || "This needs you to confirm it.",
|
|
147
|
+
fields: { "command" => command.wire.except(:id).transform_keys(&:to_s),
|
|
148
|
+
"confirm" => words(data["confirm"], LABEL) || raise(Invalid, "a confirm card needs its button's label"),
|
|
149
|
+
"done" => words(data["done"], DONE) || raise(Invalid, "a confirm card needs what it says once done") } }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param data [Hash]
|
|
153
|
+
# @param board [Hash, nil]
|
|
154
|
+
# @return [Hash]
|
|
155
|
+
# @raise [OpenLanes::Live::Copilot::Invalid]
|
|
156
|
+
def draft(data, board)
|
|
157
|
+
question = data["question"].to_s
|
|
158
|
+
raise Invalid, "#{question.inspect} is not a question id like 002/B1" unless question.match?(QUESTION)
|
|
159
|
+
|
|
160
|
+
# The board writes ids in full (`002.00/B1`); the agent may write `002/B1`.
|
|
161
|
+
plan, number = question.split("/")
|
|
162
|
+
question = "#{ordinal(plan)}/#{number}"
|
|
163
|
+
raise Invalid, "no question #{question} is waiting on this board" unless questions(board).include?(question)
|
|
164
|
+
|
|
165
|
+
draft = words(data["draft"], LIMIT) or raise Invalid, "a draft card needs the draft"
|
|
166
|
+
{ kind: "draft", text: words(data["text"], LIMIT) || "A draft answer to #{question}.",
|
|
167
|
+
fields: { "question" => question, "draft" => draft } }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The engine refuses a command for a plan or a car it does not have,
|
|
171
|
+
# but only once the person has pressed the button. Checking here keeps
|
|
172
|
+
# a card for something that is not running off the page altogether.
|
|
173
|
+
#
|
|
174
|
+
# @param command [OpenLanes::Live::Command]
|
|
175
|
+
# @param board [Hash, nil]
|
|
176
|
+
# @return [void]
|
|
177
|
+
# @raise [OpenLanes::Live::Copilot::Invalid]
|
|
178
|
+
def on_board!(command, board)
|
|
179
|
+
raise Invalid, "no run is publishing a board here, so there is nothing to act on" if board.nil?
|
|
180
|
+
return unless Command::PLAN_KINDS.include?(command.kind)
|
|
181
|
+
|
|
182
|
+
plan = Array(board["plans"]).find { ordinal(it["ordinal"]) == ordinal(command.plan) }
|
|
183
|
+
raise Invalid, "plan #{command.plan} is not on this board" unless plan
|
|
184
|
+
return unless Command::AGENT_KINDS.include?(command.kind)
|
|
185
|
+
return if Array(plan["cars"]).any? { it["agent"] == command.agent }
|
|
186
|
+
|
|
187
|
+
raise Invalid, "#{command.agent} is not working #{command.plan}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @param board [Hash, nil]
|
|
191
|
+
# @return [Array<String>] every question id waiting on the board
|
|
192
|
+
def questions(board) = Array(board&.dig("plans")).flat_map { Array(it["questions"]) }.map { it["id"].to_s }
|
|
193
|
+
|
|
194
|
+
# @param value [Object]
|
|
195
|
+
# @return [String, nil]
|
|
196
|
+
def ordinal(value) = Plans::Ordinal.parse(value.to_s)&.to_s || value.to_s
|
|
197
|
+
|
|
198
|
+
# @param value [Object]
|
|
199
|
+
# @param limit [Integer]
|
|
200
|
+
# @return [String, nil] stripped, nil when empty
|
|
201
|
+
# @raise [OpenLanes::Live::Copilot::Invalid] when longer than +limit+
|
|
202
|
+
def words(value, limit)
|
|
203
|
+
return nil unless value.is_a?(String)
|
|
204
|
+
|
|
205
|
+
text = value.strip
|
|
206
|
+
return nil if text.empty?
|
|
207
|
+
raise Invalid, "#{text[0, 24].inspect}… is longer than #{limit} characters" if text.length > limit
|
|
208
|
+
|
|
209
|
+
text
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# What a card the gate would not take becomes: its body as text, with
|
|
213
|
+
# the reason, so the person sees what was meant and nothing is sent.
|
|
214
|
+
#
|
|
215
|
+
# @param body [String]
|
|
216
|
+
# @param reason [String]
|
|
217
|
+
# @return [Hash]
|
|
218
|
+
def refused(body, reason)
|
|
219
|
+
{ kind: "text",
|
|
220
|
+
text: "I wrote a card the control room does not accept (#{reason}), so nothing was offered. " \
|
|
221
|
+
"What I wrote:\n\n#{body.strip[0, PROSE]}" }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
class Copilot
|
|
8
|
+
# c3po-operator itself (W17): the responder the API server hands
|
|
9
|
+
# {Copilot}. Each message the person sends becomes one `claude -p`,
|
|
10
|
+
# resumed from the conversation's session, so the agent remembers what
|
|
11
|
+
# was said before; it answers in prose and `c3po` cards, which {Cards}
|
|
12
|
+
# turns into the turns the page draws.
|
|
13
|
+
#
|
|
14
|
+
# It runs in the API server because that is where the conversation
|
|
15
|
+
# already arrives and is stored: the server takes the message, answers
|
|
16
|
+
# it, and writes the answer to the stream the page follows. It needs no
|
|
17
|
+
# engine, so it also answers under `openlanes web` alone, after a run
|
|
18
|
+
# has ended. The server calls it through {Copilot#answer} on its
|
|
19
|
+
# background path ({Web::Context#background}), apart from the request,
|
|
20
|
+
# and on that thread's own Redis connection; a failure that escapes
|
|
21
|
+
# here is answered for by {Copilot#answer}. Messages are answered one
|
|
22
|
+
# at a time, so two `claude` processes never resume one session at once.
|
|
23
|
+
#
|
|
24
|
+
# The boundary is the plan agents' boundary, narrowed. The tools are
|
|
25
|
+
# fixed to {TOOLS} and the Bash commands to the agent's `allowed_tools`,
|
|
26
|
+
# the read-only `openlanes` queries; {Execution::Executor#denied_for}
|
|
27
|
+
# adds the network tools and every command that leaves the machine, and
|
|
28
|
+
# {WRITE_TOOLS} goes on top. And it acts on nothing: a card is a
|
|
29
|
+
# proposal, sent only when the person presses its button.
|
|
30
|
+
class Operator
|
|
31
|
+
# The agent's definition, in `agents/`.
|
|
32
|
+
NAME = "c3po-operator"
|
|
33
|
+
|
|
34
|
+
# The only built-in tools it has: reading files, and Bash for the
|
|
35
|
+
# `openlanes` queries its definition allows.
|
|
36
|
+
TOOLS = %w[Read Grep Glob Bash].freeze
|
|
37
|
+
|
|
38
|
+
# Denied as well, should a CLI or a settings file offer them anyway:
|
|
39
|
+
# it never edits code or plan documents (W17), and a sub-agent would
|
|
40
|
+
# start with tools of its own.
|
|
41
|
+
WRITE_TOOLS = %w[Edit Write MultiEdit NotebookEdit Task Agent].freeze
|
|
42
|
+
|
|
43
|
+
# What `claude -p --resume` prints, on stderr, for a session it does not have.
|
|
44
|
+
MISSING = /No conversation found with session ID/
|
|
45
|
+
|
|
46
|
+
# Seconds an answer may take when the definition names none.
|
|
47
|
+
TIMEOUT = 180
|
|
48
|
+
|
|
49
|
+
# @param root [String] the checkout
|
|
50
|
+
# @param plans_dir [String]
|
|
51
|
+
# @param stores [#current] a store per thread; the answering thread
|
|
52
|
+
# takes its own, which the server's background path releases
|
|
53
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
54
|
+
# @param agent [OpenLanes::Agents::Agent]
|
|
55
|
+
# @param spawn [Proc] `argv, chdir:, env:` → {Execution::Child}; the suite's seam
|
|
56
|
+
# @param trace_dir [String] where each answer's raw stream is kept, and
|
|
57
|
+
# where the agents' transcripts are read from
|
|
58
|
+
def initialize(root:, plans_dir:, stores:, keys:, agent: Agents.find(NAME), spawn: Execution::Child.method(:spawn),
|
|
59
|
+
trace_dir: Execution::Executor::TRACE_DIR)
|
|
60
|
+
raise OpenLanes::Error, "no agent definition named #{NAME}" unless agent
|
|
61
|
+
|
|
62
|
+
@root = File.expand_path(root)
|
|
63
|
+
@plans_dir = plans_dir
|
|
64
|
+
@stores = stores
|
|
65
|
+
@keys = keys
|
|
66
|
+
@agent = agent
|
|
67
|
+
@spawn = spawn
|
|
68
|
+
@trace_dir = trace_dir
|
|
69
|
+
@mutex = Mutex.new
|
|
70
|
+
@turns = Mutex.new
|
|
71
|
+
@child = nil
|
|
72
|
+
@stopped = false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Answer one message, the responder's side of {Copilot#answer}: run
|
|
76
|
+
# the agent, keep its session and what it spent, and turn what it
|
|
77
|
+
# said into turns. A run that fails is answered as text saying why; a
|
|
78
|
+
# message that waits while another is answered waits its turn.
|
|
79
|
+
#
|
|
80
|
+
# @param turn [OpenLanes::Live::Copilot::Turn] the person's
|
|
81
|
+
# @return [Array<OpenLanes::Live::Copilot::Turn>]
|
|
82
|
+
# @raise [OpenLanes::Error] when the server is stopping
|
|
83
|
+
def call(turn) = @turns.synchronize { answer(turn) }
|
|
84
|
+
|
|
85
|
+
# @param turn [OpenLanes::Live::Copilot::Turn]
|
|
86
|
+
# @return [Array<OpenLanes::Live::Copilot::Turn>]
|
|
87
|
+
def answer(turn)
|
|
88
|
+
raise OpenLanes::Error, "the server is stopping" if @stopped
|
|
89
|
+
|
|
90
|
+
board = Live.board(store:, keys: @keys)
|
|
91
|
+
brief = Brief.new(text: turn.text, board: board[:me], others: board[:others], plans_dir: @plans_dir, trace_dir: @trace_dir)
|
|
92
|
+
record = session
|
|
93
|
+
run = invoke(brief.to_s, record["session"])
|
|
94
|
+
# A session `claude` no longer has (its history was cleared, or the
|
|
95
|
+
# conversation was started on another machine) fails at once; the
|
|
96
|
+
# conversation starts again rather than staying broken.
|
|
97
|
+
if stale?(run, record)
|
|
98
|
+
store.hdel(@keys.copilot_session, "session")
|
|
99
|
+
run = invoke(brief.to_s, nil)
|
|
100
|
+
end
|
|
101
|
+
usage = spend(run, record)
|
|
102
|
+
return [failure(turn, run, usage)] unless run.ok
|
|
103
|
+
|
|
104
|
+
Cards.turns(run.transcript.result, id: turn.id, usage:, board: board[:me])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Stop answering: the message under way is cut short, and any still
|
|
108
|
+
# waiting is answered with why. The server calls it on its way down.
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
def stop
|
|
112
|
+
child = @mutex.synchronize do
|
|
113
|
+
@stopped = true
|
|
114
|
+
@child
|
|
115
|
+
end
|
|
116
|
+
child&.terminate(grace: 1, drain: 1)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The argv one answer runs, exposed so a spec can check the boundary
|
|
120
|
+
# without starting anything.
|
|
121
|
+
#
|
|
122
|
+
# @param prompt [String]
|
|
123
|
+
# @param session [String, nil] the conversation to carry on
|
|
124
|
+
# @return [Array<String>]
|
|
125
|
+
def argv(prompt, session)
|
|
126
|
+
profile.adapter.argv(
|
|
127
|
+
Adapters::Invocation.new(prompt:,
|
|
128
|
+
root: @root,
|
|
129
|
+
model: profile.model,
|
|
130
|
+
effort: profile.effort,
|
|
131
|
+
allowed_tools: @agent.allowed_tools,
|
|
132
|
+
denied_tools: executor.denied_for(@agent) + WRITE_TOOLS,
|
|
133
|
+
denied_commands: executor.denied_commands(@agent),
|
|
134
|
+
network: @agent.network,
|
|
135
|
+
resume: (session if profile.adapter.resumes?),
|
|
136
|
+
dirs: [@trace_dir],
|
|
137
|
+
brief: false,
|
|
138
|
+
tools: TOOLS,
|
|
139
|
+
system: @agent.prompt)
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [OpenLanes::Agents::Profile]
|
|
144
|
+
def profile = @profile ||= Agents::Profile.resolve(@agent)
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# What one `claude -p` did.
|
|
149
|
+
Run = Data.define(:ok, :transcript, :note)
|
|
150
|
+
|
|
151
|
+
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore] this thread's
|
|
152
|
+
def store = @stores.current
|
|
153
|
+
|
|
154
|
+
# @param prompt [String]
|
|
155
|
+
# @param session [String, nil]
|
|
156
|
+
# @return [Run]
|
|
157
|
+
def invoke(prompt, session)
|
|
158
|
+
FileUtils.mkdir_p(@trace_dir)
|
|
159
|
+
trace = File.join(@trace_dir,
|
|
160
|
+
format("%s-copilot-%s-%d-%04x.ndjson",
|
|
161
|
+
Time.now.strftime("%Y%m%d-%H%M%S"),
|
|
162
|
+
NAME,
|
|
163
|
+
Process.pid,
|
|
164
|
+
rand(0x10000)))
|
|
165
|
+
transcript = profile.adapter.transcript(trace:)
|
|
166
|
+
child = @spawn.call(argv(prompt, session), chdir: @root, env: profile.adapter.environment)
|
|
167
|
+
# {#stop} may have run while this was starting, and seen no child to end.
|
|
168
|
+
stopping = @mutex.synchronize do
|
|
169
|
+
@child = child
|
|
170
|
+
@stopped
|
|
171
|
+
end
|
|
172
|
+
child.terminate(grace: 1, drain: 1) if stopping
|
|
173
|
+
status = watched(child) { child.each_chunk { transcript.push(it) } }
|
|
174
|
+
transcript.finish
|
|
175
|
+
Run.new(ok: status.success? && !transcript.failed? && !@timed_out, transcript:, note: note(status, transcript, trace))
|
|
176
|
+
ensure
|
|
177
|
+
transcript&.finish
|
|
178
|
+
@mutex.synchronize { @child = nil }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Wait for the child, ending it once {#timeout} has passed. The
|
|
182
|
+
# answer is advisory; a copilot that hangs must not hold the next
|
|
183
|
+
# message for ever.
|
|
184
|
+
#
|
|
185
|
+
# @param child [OpenLanes::Execution::Child]
|
|
186
|
+
# @return [Process::Status]
|
|
187
|
+
def watched(child)
|
|
188
|
+
@timed_out = false
|
|
189
|
+
watchdog = Thread.new do
|
|
190
|
+
sleep(timeout)
|
|
191
|
+
@timed_out = true
|
|
192
|
+
child.terminate
|
|
193
|
+
end
|
|
194
|
+
yield
|
|
195
|
+
child.wait
|
|
196
|
+
ensure
|
|
197
|
+
watchdog&.kill
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @return [Boolean] whether `claude` said it has no such session, on one
|
|
201
|
+
# it was asked to resume; any other failure is reported, not retried
|
|
202
|
+
def stale?(run, record)
|
|
203
|
+
!run.ok && record["session"] && [run.transcript.error, *run.transcript.plain].any? { it.to_s.match?(MISSING) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [Integer] seconds
|
|
207
|
+
def timeout = @agent.timeout || TIMEOUT
|
|
208
|
+
|
|
209
|
+
# @return [String] why an answer failed, for the person
|
|
210
|
+
def note(status, transcript, trace)
|
|
211
|
+
return "it took longer than #{timeout} seconds" if @timed_out
|
|
212
|
+
return "#{profile.adapter.executable} reported: #{transcript.error}" if transcript.failed?
|
|
213
|
+
return "#{profile.adapter.executable} exited #{status.exitstatus}: #{transcript.plain.last(3).join(" ")}" unless status.success?
|
|
214
|
+
|
|
215
|
+
"trace: #{trace}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The session, the model and the running total, kept in Redis
|
|
219
|
+
# ({Keys#copilot_session}) so the next message, from this server or
|
|
220
|
+
# one started after it, carries on the same conversation.
|
|
221
|
+
#
|
|
222
|
+
# @param run [Run]
|
|
223
|
+
# @param record [Hash{String => String}]
|
|
224
|
+
# @return [Hash] `{model, up, down}`
|
|
225
|
+
def spend(run, record)
|
|
226
|
+
usage = { model: model, up: record["up"].to_i + run.transcript.up, down: record["down"].to_i + run.transcript.down }
|
|
227
|
+
key = @keys.copilot_session
|
|
228
|
+
store.hset(key, "session", run.transcript.session) if run.transcript.session
|
|
229
|
+
store.hset(key, "model", usage[:model].to_s)
|
|
230
|
+
store.hset(key, "up", usage[:up])
|
|
231
|
+
store.hset(key, "down", usage[:down])
|
|
232
|
+
usage
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @return [Hash{String => String}]
|
|
236
|
+
def session = store.hgetall(@keys.copilot_session)
|
|
237
|
+
|
|
238
|
+
# The full id the header shows, as a car's does.
|
|
239
|
+
#
|
|
240
|
+
# @return [String, nil]
|
|
241
|
+
def model = profile.adapter.model_entry(profile.model)&.id || profile.model
|
|
242
|
+
|
|
243
|
+
# @return [OpenLanes::Live::Copilot::Turn]
|
|
244
|
+
def failure(turn, run, usage)
|
|
245
|
+
Turn.new(id: "#{turn.id}.1",
|
|
246
|
+
from: "copilot",
|
|
247
|
+
kind: "text",
|
|
248
|
+
usage:,
|
|
249
|
+
text: "c3po-operator could not answer: #{run.note}. Ask again, or look at the trace.")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# For its boundary only: who may run what, as plan agents are told.
|
|
253
|
+
#
|
|
254
|
+
# @return [OpenLanes::Execution::Executor]
|
|
255
|
+
def executor = @executor ||= Execution::Executor.new(root: @root)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|