Add review workflows, tree-shaped workflows and a workflow editor
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user