2026-10-15 16:33:17 -07:00
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
2026-10-15 19:02:33 -07:00
"net/http"
"os"
2026-10-15 16:33:17 -07:00
"path/filepath"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
)
const maxReviewFiles = 200
var reviewModels = map [ api . AgentSessionVendor ] string { api . Claude : "claude-opus-5-5" , api . Codex : "gpt-6.1-sol" }
var severities = [] string { "critical" , "high" , "medium" , "low" , "info" }
const reviewGuidance = `You are a reviewer running inside byre. Another agent is doing a task; you review the change it just made, and a human may read your review in byre's web UI.
- Your working directory is a detached checkout of the snapshot under review. Read, build and run whatever you need there, but don't edit, commit or push: the checkout is discarded when you finish, and the task's own worktree belongs to its agent.
- Report with byre's submit_review tool, exactly once, then end your turn with one line. A review that isn't submitted is lost.
2026-10-15 19:02:33 -07:00
- Start the review with ` + "`change`" + `: what the change does, in a few sentences, for someone who hasn't read it.
- Each finding says what is wrong, where (path and line), why it matters and what to do about it. Leave out praise and nits. No findings is a fine result.
- With no findings, the summary is exactly "No significant findings." and nothing else. With findings, the summary is a bulleted Markdown list, one line per finding, and nothing else.
- Show rather than quote. Give a finding's file and lines and byre shows that part of the diff, so don't paste code. When a picture would explain something better than prose, such as a data flow, call graph, state machine or schema the change alters, draw it in a ` + "```mermaid" + ` block. When the change alters something you can see, you can run it (on any free port on 127.0.0.1) and attach screenshots taken with the Playwright tools. Most reviews need neither; leave out a diagram or screenshot that adds nothing to the text.`
2026-10-15 16:33:17 -07:00
func reviewSchema () json . RawMessage {
2026-10-15 19:02:33 -07:00
images := map [ string ] any {
"type" : "array" ,
"description" : "Screenshots, only when they show something the text can't." ,
"items" : map [ string ] any {
"type" : "object" ,
"properties" : map [ string ] any {
"path" : map [ string ] any { "type" : "string" , "description" : "A PNG, JPEG, GIF or WebP file of at most 5 MiB you saved: absolute, or relative to your working directory. byre keeps a copy." },
"caption" : map [ string ] any { "type" : "string" , "description" : "One line saying what it shows." },
},
"required" : [] any { "path" },
},
}
2026-10-15 16:33:17 -07:00
b , err := json . Marshal ( map [ string ] any {
"type" : "object" ,
"properties" : map [ string ] any {
2026-10-15 19:02:33 -07:00
"change" : map [ string ] any { "type" : "string" , "description" : "What the change does, in a few sentences of Markdown, for someone who hasn't read it." },
2026-10-15 18:34:12 -07:00
"summary" : map [ string ] any { "type" : "string" , "description" : "Exactly \"No significant findings.\" when findings is empty; otherwise a bulleted Markdown list with one line per finding, and nothing else." },
2026-10-15 16:33:17 -07:00
"findings" : map [ string ] any {
"type" : "array" ,
"items" : map [ string ] any {
"type" : "object" ,
"properties" : map [ string ] any {
"severity" : map [ string ] any { "type" : "string" , "enum" : severities },
"category" : map [ string ] any { "type" : "string" , "description" : "A short label, such as correctness, security or tests." },
"title" : map [ string ] any { "type" : "string" , "description" : "One line." },
"detail" : map [ string ] any { "type" : "string" , "description" : "What is wrong, why it matters and what to do, in Markdown." },
"path" : map [ string ] any { "type" : "string" , "description" : "Repo-relative path of the file, if the finding is about one." },
2026-10-15 19:02:33 -07:00
"line" : map [ string ] any { "type" : "integer" , "description" : "Line in the file as of this snapshot; byre shows the diff around it." },
"end_line" : map [ string ] any { "type" : "integer" , "description" : "Last line, when the finding covers a range." },
"images" : images ,
2026-10-15 16:33:17 -07:00
},
"required" : [] any { "severity" , "title" , "detail" },
},
},
2026-10-15 19:02:33 -07:00
"images" : images ,
2026-10-15 16:33:17 -07:00
},
2026-10-15 19:02:33 -07:00
"required" : [] any { "change" , "summary" , "findings" },
2026-10-15 16:33:17 -07:00
})
if err != nil {
panic ( err )
}
return b
}
func ( m * Manager ) afterSnapshot ( snap * api . Snapshot ) {
if snap == nil {
return
}
ctx := context . Background ()
m . reviewMu . Lock ()
defer m . reviewMu . Unlock ()
task , err := m . store . GetTask ( ctx , snap . TaskId )
if err != nil {
slog . Error ( "load task to review" , "task" , snap . TaskId , "err" , err )
return
}
if task . Status != api . TaskStatusActive {
return
}
err = m . startReviewer ( ctx , task , * snap )
if err != nil {
slog . Error ( "start review" , "task" , task . Id , "snapshot" , snap . N , "err" , err )
}
}
func ( m * Manager ) reviewDir ( projectID , reviewID string ) string {
return filepath . Join ( m . worktreeRoot , projectID , "review-" + reviewID )
}
2026-10-15 19:02:33 -07:00
func ( m * Manager ) reviewMediaDir ( projectID , reviewID string ) string {
return m . reviewDir ( projectID , reviewID ) + "-media"
}
func playwrightMCP ( outputDir string ) mcp . Stdio {
return mcp . Stdio {
Name : "playwright" ,
Command : "npx" ,
Args : [] string { "--yes" , "@playwright/mcp@0.0.83" , "--headless" , "--isolated" , "--browser" , "chrome" , "--file-paths" , "absolute" , "--output-dir" , outputDir },
}
}
2026-10-15 16:33:17 -07:00
func ( m * Manager ) implementerVendor ( ctx context . Context , taskID string ) api . AgentSessionVendor {
sessions , err := m . store . ListSessions ( ctx , taskID )
if err != nil || len ( sessions ) == 0 {
return api . Claude
}
return sessions [ len ( sessions ) - 1 ]. Vendor
}
func reviewVendor ( implementer api . AgentSessionVendor ) api . AgentSessionVendor {
if implementer == api . Codex {
return api . Claude
}
return api . Codex
}
func ( m * Manager ) startReviewer ( ctx context . Context , task api . Task , snap api . Snapshot ) error {
project , err := m . store . GetProject ( ctx , task . ProjectId )
if err != nil {
return err
}
vendor := reviewVendor ( m . implementerVendor ( ctx , task . Id ))
model := reviewModels [ vendor ]
prompt , err := m . reviewPrompt ( ctx , task , project , snap )
if err != nil {
return err
}
2026-10-15 18:22:19 -07:00
if prompt == "" {
2026-10-15 20:07:36 -07:00
now := time . Now (). UTC ()
err = m . store . CreateReview ( ctx , api . Review {
Id : store . NewID (),
TaskId : task . Id ,
SnapshotN : snap . N ,
State : api . ReviewStateSkipped ,
CreatedAt : now ,
FinishedAt : & now ,
})
if err != nil {
return err
}
2026-10-15 18:22:19 -07:00
slog . Info ( "review skipped, snapshot has no diff" , "task" , task . Id , "snapshot" , snap . N )
return nil
}
2026-10-15 16:33:17 -07:00
reviewID := store . NewID ()
dir := m . reviewDir ( project . Id , reviewID )
err = git . AddDetachedWorktree ( project . RepoPath , dir , snap . CommitSha )
if err != nil {
return err
}
2026-10-15 19:02:33 -07:00
err = os . MkdirAll ( m . reviewMediaDir ( project . Id , reviewID ), 0 o755 )
if err != nil {
m . removeReviewDir ( project , reviewID )
return err
}
2026-10-15 16:33:17 -07:00
id := newUUID ()
sess := api . AgentSession {
Id : id ,
TaskId : task . Id ,
Vendor : vendor ,
Model : model ,
VendorSessionId : initialVendorSessionID ( vendor , id ),
State : api . AgentSessionStateIdle ,
Role : api . Reviewer ,
CreatedBy : task . CreatedBy ,
CreatedAt : time . Now (). UTC (),
}
err = m . store . CreateSession ( ctx , sess , project . Id )
if err != nil {
m . removeReviewDir ( project , reviewID )
return err
}
err = m . store . CreateReview ( ctx , api . Review {
Id : reviewID ,
TaskId : task . Id ,
SnapshotN : snap . N ,
AgentSessionId : sess . Id ,
Vendor : string ( vendor ),
Model : model ,
State : api . ReviewStateRunning ,
CreatedAt : time . Now (). UTC (),
})
if err != nil {
m . endReviewer ( ctx , sess . Id , project , reviewID )
return err
}
_ , err = m . turn ( ctx , sess , task , "byre" , prompt , nil )
if err != nil {
m . endReviewer ( ctx , sess . Id , project , reviewID )
m . failReview ( ctx , reviewID , "the reviewer didn't start: " + err . Error ())
return err
}
slog . Info ( "review started" , "review" , reviewID , "task" , task . Id , "snapshot" , snap . N , "vendor" , vendor , "model" , model )
return nil
}
func ( m * Manager ) reviewPrompt ( ctx context . Context , task api . Task , project api . Project , snap api . Snapshot ) ( string , error ) {
previous := ""
if snap . N > 1 {
prev , err := m . store . GetSnapshot ( ctx , task . Id , snap . N - 1 )
if err != nil {
return "" , err
}
previous = prev . CommitSha
}
changes , err := git . Compare ( project . RepoPath , project . DefaultBranch , snap . CommitSha , snap . TreeSha , previous )
if err != nil {
return "" , err
}
2026-10-15 21:56:59 -07:00
previous = changes . Base
2026-10-15 18:22:19 -07:00
if len ( changes . Files ) == 0 {
return "" , nil
}
2026-10-15 16:33:17 -07:00
whole , err := git . Compare ( project . RepoPath , project . DefaultBranch , snap . CommitSha , snap . TreeSha , "" )
if err != nil {
return "" , err
}
brief , err := m . store . FirstTaskMessage ( ctx , task . Id )
if err != nil {
return "" , err
}
b := & strings . Builder {}
fmt . Fprintf ( b , "Review the change the agent working on the byre task %q just made: look for bugs, security problems, missing or wrong tests, and anything that doesn't do what the task asked.\n\n" , task . Title )
fmt . Fprintf ( b , "It is snapshot %d of the task, commit %s, checked out in your working directory. The change is `git diff %s %s`; %d files changed:\n" ,
snap . N , snap . CommitSha , previous , snap . CommitSha , len ( changes . Files ))
for i , f := range changes . Files {
if i == maxReviewFiles {
fmt . Fprintf ( b , "- … and %d more\n" , len ( changes . Files ) - maxReviewFiles )
break
}
fmt . Fprintf ( b , "- %s %s (+%d − %d)\n" , f . Status , f . Path , f . Additions , f . Deletions )
}
fmt . Fprintf ( b , "\nFor context, all of the task's work so far is `git diff %s %s` (from the merge-base with %s, %d files).\n" , whole . Base , snap . CommitSha , project . DefaultBranch , len ( whole . Files ))
if brief != "" {
fmt . Fprintf ( b , "\nThe task's brief, the first message its agent got:\n\n%s\n" , quote ( clip ( brief , 4000 )))
}
b . WriteString ( "\nWhen you're done, call submit_review once with your review, then end your turn." )
return b . String (), nil
}
func quote ( s string ) string {
lines := strings . Split ( strings . TrimSpace ( s ), "\n" )
for i , l := range lines {
lines [ i ] = "> " + l
}
return strings . Join ( lines , "\n" )
}
func ( m * Manager ) submitReviewTool ( l * live ) mcp . Tool {
return mcp . Tool {
Name : "submit_review" ,
Description : "Submit your review. Call it exactly once, when you're done; its arguments are the review the human reads." ,
InputSchema : reviewSchema (),
Handler : func ( ctx context . Context , args json . RawMessage ) ( string , error ) {
return m . submitReview ( ctx , l , args )
},
}
}
func ( m * Manager ) submitReview ( ctx context . Context , l * live , raw json . RawMessage ) ( string , error ) {
result := map [ string ] any {}
err := json . Unmarshal ( raw , & result )
if err != nil {
return "" , fmt . Errorf ( "submit_review takes a JSON object: %w" , err )
}
2026-10-15 19:02:33 -07:00
if c , ok := result [ "change" ].( string ); ! ok || strings . TrimSpace ( c ) == "" {
return "" , errors . New ( "submit_review rejected, nothing was recorded: change must say what the change does" )
}
2026-10-15 16:33:17 -07:00
if _ , ok := result [ "summary" ].( string ); ! ok {
return "" , errors . New ( "submit_review rejected, nothing was recorded: summary must be a string" )
}
2026-10-15 19:02:33 -07:00
findings , ok := result [ "findings" ].([] any )
if ! ok {
2026-10-15 16:33:17 -07:00
return "" , errors . New ( "submit_review rejected, nothing was recorded: findings must be a list" )
}
2026-10-15 19:02:33 -07:00
holders := [] map [ string ] any { result }
for _ , f := range findings {
fm , ok := f .( map [ string ] any )
if ! ok {
return "" , errors . New ( "submit_review rejected, nothing was recorded: each finding must be an object" )
}
holders = append ( holders , fm )
}
pending := [] reviewImage {}
for _ , holder := range holders {
imgs , err := m . readReviewImages ( l , holder [ "images" ])
if err != nil {
return "" , fmt . Errorf ( "submit_review rejected, nothing was recorded: %w" , err )
}
pending = append ( pending , imgs ... )
}
for _ , img := range pending {
b , err := m . store . CreateBlob ( ctx , img . contentType , img . data , agentActor )
if err != nil {
return "" , err
}
img . entry [ "blob" ] = b . Id
delete ( img . entry , "path" )
}
2026-10-15 16:33:17 -07:00
err = m . store . FinishReview ( ctx , l . reviewID , api . ReviewStateDone , result , "" )
if errors . Is ( err , store . ErrNotFound ) {
return "" , errors . New ( "this review was already submitted or has ended" )
}
if err != nil {
return "" , err
}
return "Review recorded. End your turn now with one line." , nil
}
2026-10-15 19:02:33 -07:00
type reviewImage struct {
entry map [ string ] any
contentType string
data [] byte
}
func ( m * Manager ) readReviewImages ( l * live , raw any ) ([] reviewImage , error ) {
if raw == nil {
return nil , nil
}
list , ok := raw .([] any )
if ! ok {
return nil , errors . New ( "images must be a list" )
}
out := [] reviewImage {}
for _ , item := range list {
entry , ok := item .( map [ string ] any )
if ! ok {
return nil , errors . New ( "each image must be an object with a path" )
}
p , ok := entry [ "path" ].( string )
if ! ok || p == "" {
return nil , errors . New ( "each image needs a path" )
}
if ! filepath . IsAbs ( p ) {
p = filepath . Join ( l . dir , p )
}
info , err := os . Stat ( p )
if err != nil {
return nil , fmt . Errorf ( "image %s: %w" , p , err )
}
if ! info . Mode (). IsRegular () {
return nil , fmt . Errorf ( "image %s is not a file" , p )
}
if info . Size () > store . MaxBlobBytes {
return nil , fmt . Errorf ( "image %s is %d bytes; images are limited to %d MiB" , p , info . Size (), store . MaxBlobBytes >> 20 )
}
data , err := os . ReadFile ( p )
if err != nil {
return nil , fmt . Errorf ( "image %s: %w" , p , err )
}
contentType := http . DetectContentType ( data )
if ! store . ImageTypes [ contentType ] {
return nil , fmt . Errorf ( "image %s is %s; use PNG, JPEG, GIF or WebP" , p , contentType )
}
out = append ( out , reviewImage { entry : entry , contentType : contentType , data : data })
}
return out , nil
}
2026-10-15 16:33:17 -07:00
func ( m * Manager ) reviewTurnEnded ( ctx context . Context , l * live , res endResult ) error {
m . mu . Lock ()
interrupted := l . interrupted
m . mu . Unlock ()
err := m . emit ( ctx , l , agentActor , "turn.ended" , res . payload )
if err != nil {
return err
}
if res . window > 0 {
err = m . updateContext ( ctx , l , l . contextUsed , res . window )
if err != nil {
return err
}
}
reason := fmt . Sprintf ( "the reviewer ended its turn without calling submit_review: %s" , clip ( strings . TrimSpace ( res . result ), 500 ))
switch {
case interrupted :
reason = "the reviewer was interrupted"
case res . isError :
reason = fmt . Sprintf ( "the reviewer's turn ended with an error (%s): %s" , res . subtype , clip ( res . result , 500 ))
}
go m . finishReviewer ( l , reason )
return nil
}
func ( m * Manager ) finishReviewer ( l * live , reason string ) {
ctx := context . Background ()
project , err := m . store . GetProject ( ctx , l . projectID )
if err != nil {
slog . Error ( "load project to end reviewer" , "review" , l . reviewID , "err" , err )
return
}
m . endReviewer ( ctx , l . id , project , l . reviewID )
m . failReview ( ctx , l . reviewID , reason )
}
func ( m * Manager ) endReviewer ( ctx context . Context , sessionID string , project api . Project , reviewID string ) {
sess , err := m . store . GetSession ( ctx , sessionID )
if err != nil {
slog . Error ( "load reviewer session" , "session" , sessionID , "err" , err )
}
if err == nil && sess . State != api . AgentSessionStateCompleted {
err = m . retire ( ctx , sess , "byre" )
if err != nil {
slog . Error ( "retire reviewer session" , "session" , sessionID , "err" , err )
}
}
m . removeReviewDir ( project , reviewID )
}
func ( m * Manager ) removeReviewDir ( project api . Project , reviewID string ) {
err := git . RemoveDetachedWorktree ( project . RepoPath , m . reviewDir ( project . Id , reviewID ))
if err != nil {
slog . Error ( "remove review worktree" , "review" , reviewID , "err" , err )
}
2026-10-15 19:02:33 -07:00
err = os . RemoveAll ( m . reviewMediaDir ( project . Id , reviewID ))
if err != nil {
slog . Error ( "remove review media" , "review" , reviewID , "err" , err )
}
2026-10-15 16:33:17 -07:00
}
func ( m * Manager ) failReview ( ctx context . Context , reviewID , reason string ) {
err := m . store . FinishReview ( ctx , reviewID , api . ReviewStateFailed , nil , reason )
if errors . Is ( err , store . ErrNotFound ) {
return
}
if err != nil {
slog . Error ( "record failed review" , "review" , reviewID , "err" , err )
return
}
slog . Info ( "review failed" , "review" , reviewID , "reason" , reason )
}
func ( m * Manager ) cancelReviews ( ctx context . Context , task api . Task , project api . Project ) {
m . reviewMu . Lock ()
defer m . reviewMu . Unlock ()
reviews , err := m . store . ListReviews ( ctx , task . Id )
if err != nil {
slog . Error ( "list reviews to cancel" , "task" , task . Id , "err" , err )
return
}
for _ , r := range reviews {
if r . State != api . ReviewStateRunning {
continue
}
m . endReviewer ( ctx , r . AgentSessionId , project , r . Id )
m . failReview ( ctx , r . Id , "the task was closed" )
}
}
func ( m * Manager ) recoverReviews ( ctx context . Context ) error {
running , err := m . store . RunningReviews ( ctx )
if err != nil {
return err
}
for _ , r := range running {
sess , err := m . store . GetSession ( ctx , r . AgentSessionId )
if err != nil {
return err
}
if sess . State == api . AgentSessionStateRunning {
continue
}
task , err := m . store . GetTask ( ctx , r . TaskId )
if err != nil {
return err
}
project , err := m . store . GetProject ( ctx , task . ProjectId )
if err != nil {
return err
}
m . endReviewer ( ctx , r . AgentSessionId , project , r . Id )
m . failReview ( ctx , r . Id , "byre restarted before the reviewer finished" )
}
return nil
}
func reviewLine ( r api . Review ) string {
if r . State == api . ReviewStateFailed {
return fmt . Sprintf ( "review of snapshot %d failed: %s" , r . SnapshotN , clip ( r . Error , 300 ))
}
2026-10-15 20:07:36 -07:00
if r . State == api . ReviewStateSkipped {
return fmt . Sprintf ( "review of snapshot %d skipped: no changes" , r . SnapshotN )
}
2026-10-15 16:33:17 -07:00
res := map [ string ] any {}
if r . Result != nil {
res = * r . Result
}
b := & strings . Builder {}
fmt . Fprintf ( b , "review of snapshot %d: %s" , r . SnapshotN , clip ( strings . TrimSpace ( fmt . Sprint ( res [ "summary" ])), 600 ))
2026-10-15 19:02:33 -07:00
if c , ok := res [ "change" ].( string ); ok && c != "" {
fmt . Fprintf ( b , "\n change: %s" , clip ( strings . TrimSpace ( c ), 600 ))
}
2026-10-15 16:33:17 -07:00
findings , _ := res [ "findings" ].([] any )
for _ , f := range findings {
fm , _ := f .( map [ string ] any )
where := ""
if p , ok := fm [ "path" ].( string ); ok && p != "" {
where = " (" + p
if line , ok := fm [ "line" ].( float64 ); ok && line > 0 {
where += fmt . Sprintf ( ":%d" , int ( line ))
}
where += ")"
}
fmt . Fprintf ( b , "\n - [%v] %v%s: %s" , fm [ "severity" ], fm [ "title" ], where , clip ( strings . TrimSpace ( fmt . Sprint ( fm [ "detail" ])), 300 ))
}
return b . String ()
}