Files
aego/editor/panels/agent/model.go
sanspie a6822156cd
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
Finish Stage 12 editor fixes and refreshed platformer
2026-11-05 17:36:42 +03:00

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})
}