Files
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

313 lines
10 KiB
Go

// Package handlers (deliverables.go) replaces
// internal/handlers/deliverables.go. Beyond the original change
// (fileDeliverable's os.Stat call becoming a Workspace.StatFile gRPC
// call), producedPath now also recognizes the nine office-document
// tools (create_document/read_document/edit_document,
// create_spreadsheet/read_spreadsheet/edit_spreadsheet,
// create_presentation/read_presentation/edit_presentation —
// toolexec/office.go) in addition to the original
// write_file/edit_file/multi_edit_file. Those tools were added
// without a matching update here: their success messages ("created
// report.docx (4 paragraph(s), 512 bytes)", "wrote 3 cell(s) to
// budget.xlsx", "added 1 slide to deck.pptx", ...) never matched
// writeResultRe/editResultRe, so officekit would build and upload a
// perfectly valid .docx/.xlsx/.pptx (confirmed by officekit's own
// tests) and the chat UI would still never show a download card for
// it — the file existed in the workspace but was never surfaced as a
// deliverable. officeResultPathRe below matches on the one thing
// every one of those success messages reliably contains — the
// workspace path itself, recognizable by its .docx/.xlsx/.pptx
// extension — rather than each operation's own differently-worded
// phrasing, so it doesn't need one regex per operation and won't
// break again the next time an office tool's wording changes.
package handlers
import (
"context"
"html/template"
"path"
"regexp"
"strings"
agentv1 "enkly/gen/agent/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/renderkit"
)
const (
maxFileCards = 8
maxSnippetCards = 4
minSnippetLines = 3
)
type deliverableView struct {
Name string
TypeLabel string
Detail string
DownloadURL string
SnippetIndex int
}
var (
writeResultRe = regexp.MustCompile(`^wrote \d+ bytes to (.+)$`)
editResultRe = regexp.MustCompile(`^edited (.+?): (?:replaced|applied) `)
// officeResultPathRe pulls the workspace path out of any
// create_document/edit_document/create_spreadsheet/
// edit_spreadsheet/create_presentation/edit_presentation success
// message by matching the path itself (anything non-whitespace
// ending in .docx, .xlsx or .pptx) rather than the surrounding
// sentence, since office.go's nine tool handlers each phrase
// their result differently ("created %s (...)", "appended 1
// paragraph to %s", "wrote %d cell(s) to %s", "added 1 slide to
// %s", "updated slide %d of %s", ...) and every one of those
// phrasings always includes exactly that path.
officeResultPathRe = regexp.MustCompile(`\S+\.(?:docx|xlsx|pptx)\b`)
// officeDocTools is every office-document tool whose success
// message should be checked against officeResultPathRe.
// read_document/read_spreadsheet/read_presentation are excluded
// deliberately: those tools don't produce or change a file, so
// offering a download card for one would be misleading (and
// read_document's/read_spreadsheet's own result text can
// legitimately quote a .docx/.xlsx path from inside the document
// being read, which officeResultPathRe would otherwise mismatch
// as something just produced).
officeDocTools = map[string]bool{
"create_document": true,
"edit_document": true,
"create_spreadsheet": true,
"edit_spreadsheet": true,
"create_presentation": true,
"edit_presentation": true,
}
)
// producedPath replaces handlers.producedPath.
func producedPath(toolName, content string) string {
first, _, _ := strings.Cut(content, "\n")
if officeDocTools[toolName] {
return officeResultPathRe.FindString(first)
}
var m []string
switch toolName {
case "write_file":
m = writeResultRe.FindStringSubmatch(first)
case "edit_file", "multi_edit_file":
m = editResultRe.FindStringSubmatch(first)
}
if m == nil {
return ""
}
return strings.TrimSpace(m[1])
}
// cleanWorkspacePath normalizes a tool-reported path the way
// fileDeliverable and deliverablesHTMLFull already did inline —
// factored out so liveDeliverable and remainingToolCalls (chat.go's
// per-tool-call live delivery) can dedupe against the exact same key.
func cleanWorkspacePath(rel string) string {
return strings.TrimPrefix(path.Clean("/"+rel), "/")
}
func (h *Handlers) fileDeliverable(ctx context.Context, rel string) (deliverableView, bool) {
clean := cleanWorkspacePath(rel)
if clean == "" {
return deliverableView{}, false
}
stat, err := h.Workspace.StatFile(ctx, &workspacev1.StatFileRequest{Path: clean})
if err != nil || !stat.GetExists() || stat.GetIsDir() {
return deliverableView{}, false
}
name := path.Base(clean)
label := strings.ToUpper(strings.TrimPrefix(path.Ext(name), "."))
if label == "" {
label = "FILE"
}
return deliverableView{
Name: name, TypeLabel: label, Detail: humanBytes(stat.GetSize()), DownloadURL: downloadURLFor(clean),
}, true
}
// deliverablesHTML renders the card strip for the given produced paths
// and snippets, or "" when there's nothing worth showing.
func (h *Handlers) deliverablesHTMLFull(ctx context.Context, paths []string, snippets []renderkit.Snippet) string {
var views []deliverableView
seen := map[string]bool{}
for _, p := range paths {
key := cleanWorkspacePath(p)
if seen[key] {
continue
}
seen[key] = true
if v, ok := h.fileDeliverable(ctx, p); ok {
views = append(views, v)
if len(views) == maxFileCards {
break
}
}
}
var picked []renderkit.Snippet
for _, sn := range snippets {
if sn.Lines >= minSnippetLines && len(picked) < maxSnippetCards {
picked = append(picked, sn)
}
}
for n, sn := range picked {
name := "snippet" + sn.Ext
if len(picked) > 1 {
name = "snippet-" + itoa(n+1) + sn.Ext
}
if sn.Filename != "" {
name = sn.Filename
}
views = append(views, deliverableView{
Name: name, TypeLabel: sn.Label, Detail: itoa(sn.Lines) + " lines", SnippetIndex: sn.Index,
})
}
if len(views) == 0 {
return ""
}
return h.renderString("deliverables", views)
}
// deliverablesHTML is the entry point chat.go's StreamChat calls on the
// "done" event: every tool call made during the turn becomes a set of
// produced-file candidates (producedPath filters down to the
// successful write_file/edit_file/multi_edit_file ones).
func (h *Handlers) deliverablesHTML(ctx context.Context, toolCalls []*agentv1.ToolCall) string {
var paths []string
for _, tc := range toolCalls {
if tc.GetIsError() {
continue
}
if p := producedPath(tc.GetName(), tc.GetResult()); p != "" {
paths = append(paths, p)
}
}
return h.deliverablesHTMLFull(ctx, paths, nil)
}
// liveDeliverable checks whether tc — a tool call that just finished,
// mid-turn — produced or changed a file worth a download card, and
// one that hasn't already been shown live earlier in this same turn
// (shown, keyed by cleanWorkspacePath, is the running set chat.go's
// StreamChat keeps across the whole stream). This is what makes a
// document's download button appear as soon as the tool call that
// created it completes — see chat.go's TurnEvent_ToolCall case —
// rather than only once the whole turn finishes and the "done" event
// fires, which could be much later if the agent keeps working after
// creating the file. ok is false when there's nothing new to show: no
// path, an error result, a path already shown, or (StatFile coming
// back empty) a path that doesn't actually resolve to a file yet.
func (h *Handlers) liveDeliverable(ctx context.Context, streamID string, tc *agentv1.ToolCall, shown map[string]bool) (frag, key string, ok bool) {
if tc.GetIsError() {
return "", "", false
}
p := producedPath(tc.GetName(), tc.GetResult())
if p == "" {
return "", "", false
}
key = cleanWorkspacePath(p)
if shown[key] {
return "", "", false
}
v, exists := h.fileDeliverable(ctx, p)
if !exists {
return "", "", false
}
return h.renderString("deliverable_oob", struct {
StreamID string
Views []deliverableView
}{StreamID: streamID, Views: []deliverableView{v}}), key, true
}
// remainingToolCalls filters toolCalls down to the ones not already
// covered by shown, so chat.go's "done" event — which otherwise
// re-derives deliverable cards from every tool call of the whole turn
// — doesn't render a second, duplicate card for a file whose card
// already streamed live via liveDeliverable. On a resumed stream
// (StreamTurn's ResumeFromToolIndex), shown starts empty for the new
// connection, so a file created before the client reconnected still
// gets its card here as a fallback — nothing is ever silently lost,
// only de-duplicated on the common, unbroken-connection path.
func remainingToolCalls(toolCalls []*agentv1.ToolCall, shown map[string]bool) []*agentv1.ToolCall {
if len(shown) == 0 {
return toolCalls
}
out := make([]*agentv1.ToolCall, 0, len(toolCalls))
for _, tc := range toolCalls {
if !tc.GetIsError() {
if p := producedPath(tc.GetName(), tc.GetResult()); p != "" && shown[cleanWorkspacePath(p)] {
continue
}
}
out = append(out, tc)
}
return out
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// withDeliverables replaces Handlers.withDeliverables +
// Handlers.attachDeliverables: appends download cards to already-
// rendered messages when a conversation page is loaded.
func (h *Handlers) withDeliverables(ctx context.Context, msgs []messageView) []messageView {
h.attachDeliverables(ctx, msgs)
return msgs
}
func (h *Handlers) attachDeliverables(ctx context.Context, msgs []messageView) {
var (
pending []string
last = -1
)
flush := func() {
if last >= 0 && len(pending) > 0 {
if cards := h.deliverablesHTMLFull(ctx, pending, nil); cards != "" {
msgs[last].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template
}
}
pending, last = nil, -1
}
for i := range msgs {
switch msgs[i].Role {
case "user":
flush()
case "tool":
if p := producedPath(msgs[i].ToolName, msgs[i].Content); p != "" {
pending = append(pending, p)
}
case "assistant":
if cards := h.deliverablesHTMLFull(ctx, nil, renderkit.Snippets(msgs[i].Content)); cards != "" {
msgs[i].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template
}
last = i
}
}
flush()
}