ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
248 lines
6.7 KiB
Go
248 lines
6.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"time"
|
|
|
|
"aego/editor/core/agentui"
|
|
"aego/ui/core"
|
|
"aego/ui/richtext"
|
|
"aego/ui/widgets"
|
|
)
|
|
|
|
type blockKind uint8
|
|
|
|
const (
|
|
blockUser blockKind = iota
|
|
blockText
|
|
// blockSteps folds read-only tool calls that follow each other into one row.
|
|
blockSteps
|
|
// blockFailed is one tool call that failed, with Retry and Ask Agent to Fix.
|
|
blockFailed
|
|
// blockCard is a write call and the card it proposed.
|
|
blockCard
|
|
blockQuestion
|
|
blockError
|
|
// blockTurn separates the answers to two messages.
|
|
blockTurn
|
|
// blockNotice is a line about the session itself: the context compacted, a new session from a summary.
|
|
blockNotice
|
|
)
|
|
|
|
// step is one tool call; its look comes from the host, the raw result is not kept (11b5a).
|
|
type step struct {
|
|
tool, call, args string
|
|
look agentui.Step
|
|
resultHash [32]byte
|
|
done, failed bool
|
|
open, all bool
|
|
label string
|
|
fl fit
|
|
}
|
|
|
|
type block struct {
|
|
images []string
|
|
kind blockKind
|
|
text string
|
|
messageID string
|
|
md *richtext.Text
|
|
parsed string
|
|
steps []step
|
|
card int
|
|
question int
|
|
repeats int
|
|
at time.Time
|
|
open bool
|
|
all bool
|
|
h, hw float32
|
|
// pick and other are a waiting question's chosen options and typed answer.
|
|
pick []bool
|
|
other string
|
|
lines []string
|
|
lineBytes []int
|
|
fl fit
|
|
}
|
|
|
|
// dirty makes the block measured again before the next draw.
|
|
func (b *block) dirty() {
|
|
b.hw = -1
|
|
}
|
|
|
|
// fit is a string elided to a width, kept until the string or the width changes, so drawing a long transcript does not allocate.
|
|
type fit struct {
|
|
src string
|
|
w float32
|
|
out string
|
|
}
|
|
|
|
func (f *fit) get(ui *core.Context, s string, w float32) string {
|
|
if s != f.src || w != f.w || f.out == "" && s != "" {
|
|
f.src, f.w, f.out = s, w, widgets.Elide(ui, s, w)
|
|
}
|
|
return f.out
|
|
}
|
|
|
|
// stepLabel is "title · summary".
|
|
func stepLabel(s *step) string {
|
|
if s.look.Summary == "" {
|
|
return s.look.Title
|
|
}
|
|
return s.look.Title + " · " + s.look.Summary
|
|
}
|
|
|
|
func (p *Panel) last() *block {
|
|
if n := len(p.blocks); n > 0 {
|
|
return &p.blocks[n-1]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Panel) push(b block) *block {
|
|
b.hw = -1
|
|
p.blocks = append(p.blocks, b)
|
|
return &p.blocks[len(p.blocks)-1]
|
|
}
|
|
|
|
// toolStart adds a call: a write call waits for its card, a read call joins the row of the calls before it.
|
|
func (p *Panel) toolStart(e agentui.Event) {
|
|
s := step{tool: e.Tool, call: e.CallID, args: e.Args, look: p.d.Host.Describe(e.Tool, e.Args, "", false)}
|
|
s.label = stepLabel(&s)
|
|
if s.look.Write {
|
|
p.push(block{kind: blockCard, steps: []step{s}, open: true})
|
|
return
|
|
}
|
|
if b := p.last(); b != nil && b.kind == blockSteps {
|
|
b.steps = append(b.steps, s)
|
|
b.dirty()
|
|
return
|
|
}
|
|
p.push(block{kind: blockSteps, steps: []step{s}})
|
|
}
|
|
|
|
// toolResult finishes the call: its look gets the summary and the laid-out result, and a failed read call moves into a row of its own.
|
|
func (p *Panel) toolResult(e agentui.Event) {
|
|
for i := len(p.blocks) - 1; i >= 0; i-- {
|
|
b := &p.blocks[i]
|
|
for j := len(b.steps) - 1; j >= 0; j-- {
|
|
s := &b.steps[j]
|
|
if s.done || (e.CallID != "" && s.call != e.CallID) || (e.CallID == "" && s.tool != e.Tool) {
|
|
continue
|
|
}
|
|
s.done, s.failed = true, e.Failed
|
|
s.resultHash = sha256.Sum256([]byte(e.Result))
|
|
s.look = p.d.Host.Describe(s.tool, s.args, e.Result, e.Failed)
|
|
s.look.Detail.Images = e.ImageKeys
|
|
if len(e.ImageKeys) > 0 {
|
|
s.open = true
|
|
b.open = true
|
|
if s.look.Detail.Kind == agentui.DetailNone {
|
|
s.look.Detail.Kind = agentui.DetailList
|
|
}
|
|
}
|
|
s.label = stepLabel(s)
|
|
b.dirty()
|
|
if e.Failed && b.kind == blockSteps {
|
|
// Collapse only consecutive, identical failed playtests. Distinct
|
|
// arguments, results, images or intervening activity remain visible;
|
|
// each call is still recorded separately in the session transcript.
|
|
if i == len(p.blocks)-1 && i > 0 && len(b.steps) == 1 && s.tool == "scene_run" && len(e.ImageKeys) == 0 {
|
|
prev := &p.blocks[i-1]
|
|
if prev.kind == blockFailed && len(prev.steps) == 1 {
|
|
first := &prev.steps[0]
|
|
if first.tool == s.tool && first.args == s.args && first.resultHash == s.resultHash && len(first.look.Detail.Images) == 0 {
|
|
prev.repeats = max(1, prev.repeats) + 1
|
|
prev.dirty()
|
|
p.blocks = p.blocks[:i]
|
|
return
|
|
}
|
|
}
|
|
}
|
|
failed := *s
|
|
b.steps = append(b.steps[:j], b.steps[j+1:]...)
|
|
if len(b.steps) == 0 {
|
|
*b = block{kind: blockFailed, steps: []step{failed}, hw: -1}
|
|
} else {
|
|
p.insert(i+1, block{kind: blockFailed, steps: []step{failed}})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Panel) insert(at int, b block) {
|
|
b.hw = -1
|
|
p.blocks = append(p.blocks, block{})
|
|
copy(p.blocks[at+1:], p.blocks[at:])
|
|
p.blocks[at] = b
|
|
}
|
|
|
|
// attach gives a new card to the write call that proposed it, or a block of its own.
|
|
func (p *Panel) attach(c *agentui.Card) {
|
|
keys := p.storeImages(c.PreviewImages)
|
|
for i := len(p.blocks) - 1; i >= 0; i-- {
|
|
b := &p.blocks[i]
|
|
if b.kind == blockCard && b.card == 0 && len(b.steps) == 1 && b.steps[0].tool == toolName(c.Command) {
|
|
b.card = c.ID
|
|
b.steps[0].look.Detail.Images = keys
|
|
b.dirty()
|
|
return
|
|
}
|
|
}
|
|
look := p.d.Host.Describe(toolName(c.Command), c.Args, "", false)
|
|
look.Detail.Images = keys
|
|
s := step{tool: toolName(c.Command), args: c.Args, look: look, done: true}
|
|
s.label = stepLabel(&s)
|
|
p.push(block{kind: blockCard, card: c.ID, steps: []step{s}, open: c.State == agentui.Pending})
|
|
}
|
|
|
|
// toolName is the tool form of a command name: scene.add-node is scene_add_node.
|
|
func toolName(cmd string) string {
|
|
b := []byte(cmd)
|
|
for i, c := range b {
|
|
if c == '.' || c == '-' {
|
|
b[i] = '_'
|
|
}
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// mergeLimit is how long a message grows before the next text starts a block of its own, so new text lays out only the last block.
|
|
const mergeLimit = 2048
|
|
|
|
// text joins answer text into one message until the next tool call.
|
|
func (p *Panel) text(s string) {
|
|
if b := p.last(); b != nil && b.kind == blockText && len(b.text) < mergeLimit {
|
|
b.text += "\n\n" + s
|
|
b.dirty()
|
|
return
|
|
}
|
|
p.push(block{kind: blockText, text: s})
|
|
}
|
|
|
|
// textEvent preserves streamed text exactly. Item IDs keep interleaved messages
|
|
// separate; a complete item received after its deltas replaces that same block.
|
|
func (p *Panel) textEvent(e agentui.Event) {
|
|
if !e.Delta && e.MessageID == "" {
|
|
p.text(e.Text)
|
|
return
|
|
}
|
|
for i := len(p.blocks) - 1; i >= 0; i-- {
|
|
b := &p.blocks[i]
|
|
if b.kind != blockText || b.messageID != e.MessageID {
|
|
continue
|
|
}
|
|
if e.MessageID == "" && i != len(p.blocks)-1 {
|
|
break
|
|
}
|
|
if e.Delta {
|
|
b.text += e.Text
|
|
} else {
|
|
b.text = e.Text
|
|
}
|
|
b.dirty()
|
|
return
|
|
}
|
|
p.push(block{kind: blockText, text: e.Text, messageID: e.MessageID})
|
|
}
|