Add review workflows, tree-shaped workflows and a workflow editor

This commit is contained in:
Ian Gulliver
2026-10-20 18:52:04 -07:00
parent 9869fad988
commit f2188c53eb
31 changed files with 2179 additions and 863 deletions
+123 -35
View File
@@ -46,6 +46,7 @@ enum RowKind {
case thinking(md: [MDBlock])
case tool(name: String, brief: String, input: String)
case subagent(title: String, description: String, prompt: [MDBlock])
case reviewer(model: String)
case group
case orphan(String)
case turnEnd(error: String?)
@@ -123,6 +124,17 @@ final class Row: Identifiable {
return false
}
var inReview: Bool {
var o = owner
while let r = o {
if case .reviewer = r.kind {
return r.active
}
o = r.owner
}
return false
}
func within(_ ancestor: Row) -> Bool {
var o = owner
while let r = o {
@@ -193,7 +205,7 @@ private final class Subagent {
private final class Ctx {
var tools: [String: Row] = [:]
var subagents: [String: Subagent] = [:]
var reviewers: Set<String> = []
var reviewers: [String: Row] = [:]
var snapshots: [String: Row] = [:]
var reviews: [String: Review] = [:]
var reviewRows: [String: [Row]] = [:]
@@ -378,7 +390,7 @@ final class TranscriptBuilder {
if ctx.handoff != nil {
return
}
if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) {
if let sid = ev.agentSessionId, ctx.reviewers[sid] != nil {
return
}
let row: Row
@@ -437,10 +449,17 @@ final class TranscriptBuilder {
rowTask = ev.taskId ?? taskId
let own = rowTask == taskId
if ev.kind == "session.created" && str(p, "role") == "reviewer", let sid = ev.agentSessionId, !sid.isEmpty {
ctx.reviewers.insert(sid)
let row = Row(.reviewer(model: str(p, "model") ?? ""), taskId: rowTask, body: true)
row.status = "running"
row.active = true
ctx.reviewers[sid] = row
ctx.subagents[sid] = Subagent(row: row)
ctx.run = nil
put(row)
continue
}
if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) {
if let sid = ev.agentSessionId, let reviewer = ctx.reviewers[sid] {
reviewerEvent(ev, reviewer, ctx.subagents[parent] == nil ? sid : parent)
continue
}
if ev.kind == "agent.vendor" {
@@ -449,7 +468,7 @@ final class TranscriptBuilder {
}
if ev.kind == "session.subagents" {
if int(p, "subagents") == 0 {
for sub in ctx.subagents.values where sub.row.active {
for sub in ctx.subagents.values where sub.row.active && !ctx.reviewers.values.contains(where: { $0 === sub.row }) {
endSubagent(sub.row, .error, "ended with its process")
}
}
@@ -517,11 +536,13 @@ final class TranscriptBuilder {
}
if ev.kind == "review.started" || ev.kind == "review.finished" || ev.kind == "review.finding" {
let r = try! decodePayload(ev.payload, as: Review.self)
ctx.snapshots[snapKey(r.snapshotN)]?.review = r
ctx.reviews[snapKey(r.snapshotN)] = r
ctx.reviews[reviewKey(r.snapshotN, r.stage)] = r
for row in ctx.reviewRows[r.id] ?? [] {
row.review = r
}
if let reviewer = ctx.reviewers[r.agentSessionId] {
reviewerState(reviewer, r)
}
if ev.kind == "review.finished" {
let rows = renderReview(r, ev.createdAt)
if !rows.isEmpty {
@@ -546,7 +567,7 @@ final class TranscriptBuilder {
}
let sub = ctx.subagents[parent]
if ev.kind == "agent.tool_call" && isSubagentCall(p) {
let row = renderSubagent(p)
let row = renderSubagent(p, parent)
row.started = ev.createdAt
if let sub {
sub.run = nil
@@ -562,7 +583,7 @@ final class TranscriptBuilder {
let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: rowTask)
row.started = ev.createdAt
ctx.tools[jsString(p["id"])] = row
if let r = ctx.reviews[snapKey(n)], r.state == .done {
if let r = ctx.reviews[reviewKey(n, str(input, "reviewer") ?? "")], r.state == .done {
row.review = r
ctx.reviewRows[r.id, default: []].append(row)
}
@@ -572,7 +593,7 @@ final class TranscriptBuilder {
}
if ev.kind == "agent.tool_call" {
let name = toolLabel(jsString(p["name"]))
let row = renderToolCall(p)
let row = renderToolCall(p, parent)
row.started = ev.createdAt
if name == "ToolSearch" {
continue
@@ -580,7 +601,7 @@ final class TranscriptBuilder {
addToRun(row, Call(name: name, target: toolTarget(p["input"] ?? nil, worktree())), parent)
continue
}
guard let row = renderEvent(ev) else {
guard let row = renderEvent(ev, parent) else {
continue
}
if let sub {
@@ -653,6 +674,10 @@ final class TranscriptBuilder {
"\(rowTask ?? "")/\(n)"
}
private func reviewKey(_ n: Int, _ stage: String) -> String {
"\(snapKey(n))/\(stage)"
}
private func claudeTaskStatus(_ p: JSONDict) {
guard str(p, "type") == "system", let sub = ctx.subagents[jsString(p["tool_use_id"])] else {
return
@@ -685,36 +710,97 @@ final class TranscriptBuilder {
}
}
private func renderToolCall(_ p: JSONDict) -> Row {
private func renderToolCall(_ p: JSONDict, _ parent: String) -> Row {
let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
row.tag = subagentTag(parent)
row.owner = ctx.subagents[parent]?.row
ctx.tools[jsString(p["id"])] = row
return row
}
private func renderSubagent(_ p: JSONDict) -> Row {
private func renderSubagent(_ p: JSONDict, _ parent: String) -> Row {
let input = dict(p, "input") ?? [:]
let id = jsString(p["id"])
let type = str(input, "subagent_type") ?? "subagent"
let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: rowTask, body: true)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
row.tag = subagentTag(parent)
row.owner = ctx.subagents[parent]?.row
ctx.tools[id] = row
ctx.subagents[id] = Subagent(row: row)
return row
}
private func reviewerEvent(_ ev: ByreEvent, _ reviewer: Row, _ parent: String) {
let p = ev.payload.value
let sub = ctx.subagents[parent]!
switch ev.kind {
case "agent.vendor":
claudeTaskStatus(p)
case "user.message":
addToRun(byrePrompt(str(p, "text") ?? "", ev.createdAt), Call(name: "byre", target: ""), parent)
case "agent.tool_call":
let name = toolLabel(jsString(p["name"]))
reviewer.text = "\(name) \(toolBrief(p["input"] ?? nil, worktree()))"
if isSubagentCall(p) {
let row = renderSubagent(p, parent)
row.started = ev.createdAt
if sub.row !== reviewer {
sub.run = nil
sub.row.body!.rows.append(row)
return
}
addToRun(row, Call(name: jsString(p["name"]), target: ""), parent)
return
}
let row = renderToolCall(p, parent)
row.started = ev.createdAt
if name != "ToolSearch" {
addToRun(row, Call(name: name, target: toolTarget(p["input"] ?? nil, worktree())), parent)
}
case "turn.ended":
for tool in ctx.tools.values where tool.result == nil && tool.within(reviewer) {
tool.unfinished = true
}
case "agent.message", "agent.thinking", "agent.tool_result", "session.state", "session.error", "session.rate_limited":
if ev.kind == "agent.message" && parent == ev.agentSessionId {
reviewer.text = trimmed(jsString(p["text"])).components(separatedBy: "\n").first ?? ""
}
guard let row = renderEvent(ev, parent) else {
return
}
sub.run = nil
sub.row.body!.rows.append(row)
default:
break
}
}
private func reviewerState(_ row: Row, _ r: Review) {
row.review = r
row.reviewOf = r.snapshotN
switch r.state {
case .done:
endSubagent(row, .done, reviewCounts(r))
row.text = ""
case .failed:
let superseded = r.error.hasPrefix("superseded")
endSubagent(row, superseded ? .done : .error, superseded ? "superseded" : "failed")
row.text = r.error
default:
break
}
}
private func renderReview(_ r: Review, _ when: Date) -> [Row] {
if r.state == .failed && r.agentSessionId.isEmpty {
return [Row(.note(Note(parts: [.text("\(r.stage) review of snapshot #\(r.snapshotN) failed: \(r.error)")], when: when, style: .error)), taskId: rowTask)]
}
let fs = findings(r)
if fs.isEmpty && r.state != .failed {
if fs.isEmpty {
return []
}
let outcome = r.state == .failed ? "failed: \(r.error)" : reviewCounts(r)
let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")]
let head = fs.isEmpty
? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: rowTask)
: Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask)
let parts: [NotePart] = [.text("\(r.stage) review of snapshot #\(r.snapshotN): \(reviewCounts(r))")]
let head = Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask)
let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: rowTask) }
for row in rows {
row.review = r
@@ -724,7 +810,13 @@ final class TranscriptBuilder {
return rows
}
private func renderEvent(_ ev: ByreEvent) -> Row? {
private func byrePrompt(_ text: String, _ when: Date) -> Row {
let first = text.components(separatedBy: "\n").first ?? ""
let brief = first.count > 120 ? "\(first.prefix(120))…" : first
return Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask)
}
private func renderEvent(_ ev: ByreEvent, _ parent: String) -> Row? {
let p = ev.payload.value
let when = ev.createdAt
func note(_ parts: [NotePart], _ style: NoteStyle = .plain, time: Bool = true) -> Row {
@@ -739,9 +831,7 @@ final class TranscriptBuilder {
}
let text = str(p, "text") ?? ""
if ev.actor == "byre" {
let first = text.components(separatedBy: "\n").first ?? ""
let brief = first.count > 120 ? "\(first.prefix(120))…" : first
let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask)
let row = byrePrompt(text, when)
row.hidden = bool(p, "continuation") || bool(p, "findings")
return row
}
@@ -750,9 +840,9 @@ final class TranscriptBuilder {
case "agent.message":
let text = jsString(p["text"])
if !parentOf(p).isEmpty {
if !parent.isEmpty {
let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.tag = subagentTag(parent)
return row
}
if trimmed(trimmed(text).components(separatedBy: "\n").last ?? "") == quietReply {
@@ -764,7 +854,7 @@ final class TranscriptBuilder {
case "agent.thinking":
let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.tag = subagentTag(parent)
return row
case "agent.tool_result":
@@ -906,13 +996,11 @@ func findingCount(_ r: Review) -> Int {
r.state == .done ? findings(r).indices.filter { findingOpen(r, $0) }.count : 0
}
func latestReview(_ events: ArraySlice<ByreEvent>) -> Review? {
var best: Review?
func latestReviewedSnapshot(_ events: ArraySlice<ByreEvent>) -> Int? {
var best: Int?
for ev in events where isReviewEvent(ev) {
let r = try! decodePayload(ev.payload, as: Review.self)
if best == nil || r.snapshotN >= best!.snapshotN {
best = r
}
best = max(best ?? r.snapshotN, r.snapshotN)
}
return best
}