Files
enkly-ai/services/agent/internal/infrastructure/toolexec/office.go
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

452 lines
16 KiB
Go

// Document-management tools (create/read/edit_document,
// create/read/edit_spreadsheet, create/read/edit_presentation) —
// Word/Excel/PowerPoint support modeled on Claude Code's
// document-editing skills. All the actual OOXML work happens in
// pkg/officekit; this file just translates tool-call arguments to/from
// it and persists bytes through the workspace service's existing
// UploadFile/DownloadFile RPCs (binary-safe []byte fields — unlike
// ReadFile/WriteFile's text-oriented string fields, which real .docx/
// .xlsx/.pptx bytes wouldn't survive intact).
//
// Deliberately, nothing here ever puts raw XML into a ToolResult.Content
// or reads raw XML out of call.Arguments — every value crossing that
// boundary is plain text (a title, a paragraph, a cell value, a short
// summary like "wrote report.docx (4 paragraphs)"). That's what keeps
// these new tools from being a new way to trigger a tool-call parse
// failure on the model's output — the historical Ollama chat-template
// "XML syntax error" failure mode described in catalog.go's comment
// above these tool defs, or vLLM's own tool-call parser choking on
// unexpected markup in an argument or result.
package toolexec
import (
"context"
"fmt"
"strings"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/officekit"
"enkly/pkg/vllmkit"
)
func (e *Executor) download(ctx context.Context, path string) ([]byte, error) {
resp, err := e.Workspace.DownloadFile(ctx, &workspacev1.DownloadFileRequest{Path: path})
if err != nil {
return nil, err
}
return resp.GetContent(), nil
}
func (e *Executor) upload(ctx context.Context, path string, data []byte) error {
_, err := e.Workspace.UploadFile(ctx, &workspacev1.UploadFileRequest{Path: path, Content: data})
return err
}
// errResult is a small helper matching the rest of this package's
// vllmkit.ToolResult{IsError: true} convention.
func errResult(name, format string, a ...any) vllmkit.ToolResult {
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf(format, a...), IsError: true}
}
// --- Word ---------------------------------------------------------
func parseParagraphArg(m map[string]any) officekit.Paragraph {
style := officekit.ParaStyle(argStr(m, "style"))
if style == "" {
style = officekit.StyleNormal
}
return officekit.Paragraph{
Text: argStr(m, "text"),
Style: style,
Bold: argBool(m, "bold"),
Italic: argBool(m, "italic"),
Underline: argBool(m, "underline"),
}
}
func (e *Executor) createDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
if !strings.HasSuffix(strings.ToLower(path), ".docx") {
return errResult(call.Name, "create_document: path must end in .docx, got %q", path)
}
if existing, _ := e.download(ctx, path); len(existing) > 0 {
return errResult(call.Name, "create_document: %s already exists; use edit_document to change it", path)
}
var paras []officekit.Paragraph
if md := argStr(call.Arguments, "markdown"); md != "" {
// Conversion path: let officekit's Markdown parser do the
// heading/bullet/paragraph structuring instead of the model
// building "paragraphs" entries by hand — see markdown.go's
// package doc for exactly what carries over and what doesn't.
paras = officekit.MarkdownToParagraphs(md)
if len(paras) == 0 {
return errResult(call.Name, "create_document: markdown produced no recognizable content (headings/paragraphs/lists)")
}
} else {
rawParas, _ := call.Arguments["paragraphs"].([]any)
paras = make([]officekit.Paragraph, 0, len(rawParas))
for _, rp := range rawParas {
if m, ok := rp.(map[string]any); ok {
paras = append(paras, parseParagraphArg(m))
}
}
}
data, err := officekit.BuildDocx(paras)
if err != nil {
return errResult(call.Name, "create_document: %v", err)
}
if err := e.upload(ctx, path, data); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d paragraph(s), %d bytes)", path, len(paras), len(data))}
}
func (e *Executor) readDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_document: %s not found or empty", path)
}
text, err := officekit.ReadDocxText(data)
if err != nil {
return errResult(call.Name, "read_document: %v", err)
}
// Surface any pending tracked changes alongside the plain text so
// a caller reviewing a document doesn't need a second tool call to
// discover it has open redlines — see redline.go's ReadDocxRevisions.
if revisions, rerr := officekit.ReadDocxRevisions(data); rerr == nil && revisions != "" {
text += "\n\n--- tracked changes pending review (edit_document: accept_all_changes / reject_all_changes) ---\n" + revisions
}
return vllmkit.ToolResult{Name: call.Name, Content: text}
}
func (e *Executor) editDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "edit_document: %s not found", path)
}
switch argStr(call.Arguments, "operation") {
case "append_paragraph":
p := parseParagraphArg(call.Arguments)
out, err := officekit.AppendDocxParagraph(data, p)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s", path)}
case "replace_text":
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
if find == "" {
return errResult(call.Name, "edit_document: replace_text requires a non-empty \"find\"")
}
out, n, err := officekit.ReplaceDocxText(data, find, replace)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("replaced text in %d paragraph(s) of %s", n, path)}
case "replace_text_tracked":
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
if find == "" {
return errResult(call.Name, "edit_document: replace_text_tracked requires a non-empty \"find\"")
}
out, n, err := officekit.ReplaceDocxTextTracked(data, find, replace, argStr(call.Arguments, "author"))
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("tracked a change in %d run(s) of %s, pending review (Word: Review > Track Changes)", n, path)}
case "append_paragraph_tracked":
p := parseParagraphArg(call.Arguments)
out, err := officekit.AppendDocxParagraphTracked(data, p, argStr(call.Arguments, "author"))
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s as a tracked insertion, pending review", path)}
case "accept_all_changes":
out, err := officekit.AcceptAllDocxChanges(data)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("accepted all tracked changes in %s", path)}
case "reject_all_changes":
out, err := officekit.RejectAllDocxChanges(data)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("rejected all tracked changes in %s (original text restored)", path)}
default:
return errResult(call.Name, "edit_document: unknown operation %q (expected append_paragraph, replace_text, append_paragraph_tracked, replace_text_tracked, accept_all_changes, or reject_all_changes)", argStr(call.Arguments, "operation"))
}
}
// --- Excel ----------------------------------------------------------
func (e *Executor) createSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
if !strings.HasSuffix(strings.ToLower(path), ".xlsx") {
return errResult(call.Name, "create_spreadsheet: path must end in .xlsx, got %q", path)
}
if existing, _ := e.download(ctx, path); len(existing) > 0 {
return errResult(call.Name, "create_spreadsheet: %s already exists; use edit_spreadsheet to change it", path)
}
sheets := parseSheetsArg(call.Arguments)
if len(sheets) == 0 {
return errResult(call.Name, "create_spreadsheet: give either \"rows\" (one sheet) or \"sheets\" (multiple)")
}
data, err := officekit.BuildXlsx(sheets)
if err != nil {
return errResult(call.Name, "create_spreadsheet: %v", err)
}
if err := e.upload(ctx, path, data); err != nil {
return rpcFail(call.Name, err)
}
totalRows := 0
for _, s := range sheets {
totalRows += len(s.Rows)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d sheet(s), %d row(s) total, %d bytes)", path, len(sheets), totalRows, len(data))}
}
// parseSheetsArg accepts either the multi-sheet "sheets" array
// (each {name, rows}) or the original single-sheet "rows"/"sheet_name"
// pair, so existing callers keep working unchanged while the model can
// now also build a workbook with more than one tab in one call.
func parseSheetsArg(args map[string]any) []officekit.Sheet {
if rawSheets, ok := args["sheets"].([]any); ok && len(rawSheets) > 0 {
sheets := make([]officekit.Sheet, 0, len(rawSheets))
for _, rs := range rawSheets {
m, ok := rs.(map[string]any)
if !ok {
continue
}
sheets = append(sheets, officekit.Sheet{Name: argStr(m, "name"), Rows: parseRowsArg(m["rows"])})
}
return sheets
}
if _, has := args["rows"]; !has {
return nil
}
return []officekit.Sheet{{Name: argStr(args, "sheet_name"), Rows: parseRowsArg(args["rows"])}}
}
func parseRowsArg(v any) [][]string {
rawRows, _ := v.([]any)
rows := make([][]string, 0, len(rawRows))
for _, rr := range rawRows {
rawRow, _ := rr.([]any)
row := make([]string, 0, len(rawRow))
for _, c := range rawRow {
s, _ := c.(string)
row = append(row, s)
}
rows = append(rows, row)
}
return rows
}
func (e *Executor) readSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_spreadsheet: %s not found or empty", path)
}
sheetRef := argStr(call.Arguments, "sheet")
rows, sheetName, err := officekit.ReadXlsxCells(data, sheetRef)
if err != nil {
return errResult(call.Name, "read_spreadsheet: %v", err)
}
var sb strings.Builder
if all, nameErr := officekit.ReadXlsxSheetNames(data); nameErr == nil && len(all) > 1 {
fmt.Fprintf(&sb, "# sheet %q of %d: %s\n", sheetName, len(all), strings.Join(all, ", "))
}
for _, row := range rows {
sb.WriteString(strings.Join(row, "\t"))
sb.WriteByte('\n')
}
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
}
func (e *Executor) editSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "edit_spreadsheet: %s not found", path)
}
rawCells, _ := call.Arguments["cells"].([]any)
writes := make(map[string]string, len(rawCells))
for _, rc := range rawCells {
m, ok := rc.(map[string]any)
if !ok {
continue
}
writes[strings.ToUpper(argStr(m, "cell"))] = argStr(m, "value")
}
if len(writes) == 0 {
return errResult(call.Name, "edit_spreadsheet: no cells given")
}
sheetRef := argStr(call.Arguments, "sheet")
out, err := officekit.SetXlsxCells(data, sheetRef, writes)
if err != nil {
return errResult(call.Name, "edit_spreadsheet: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d cell(s) to %s", len(writes), path)}
}
// --- PowerPoint -------------------------------------------------------
func parseSlideArg(m map[string]any) officekit.Slide {
rawBullets, _ := m["bullets"].([]any)
bullets := make([]string, 0, len(rawBullets))
for _, b := range rawBullets {
if s, ok := b.(string); ok {
bullets = append(bullets, s)
}
}
return officekit.Slide{Title: argStr(m, "title"), Bullets: bullets}
}
func (e *Executor) createPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
if !strings.HasSuffix(strings.ToLower(path), ".pptx") {
return errResult(call.Name, "create_presentation: path must end in .pptx, got %q", path)
}
if existing, _ := e.download(ctx, path); len(existing) > 0 {
return errResult(call.Name, "create_presentation: %s already exists; use edit_presentation to change it", path)
}
rawSlides, _ := call.Arguments["slides"].([]any)
slides := make([]officekit.Slide, 0, len(rawSlides))
for _, rs := range rawSlides {
if m, ok := rs.(map[string]any); ok {
slides = append(slides, parseSlideArg(m))
}
}
data, err := officekit.BuildPptx(slides)
if err != nil {
return errResult(call.Name, "create_presentation: %v", err)
}
if err := e.upload(ctx, path, data); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d slide(s), %d bytes)", path, len(slides), len(data))}
}
func (e *Executor) readPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_presentation: %s not found or empty", path)
}
slides, err := officekit.ReadPptxSlides(data)
if err != nil {
return errResult(call.Name, "read_presentation: %v", err)
}
var sb strings.Builder
for i, s := range slides {
fmt.Fprintf(&sb, "## Slide %d: %s\n", i+1, s.Title)
for _, b := range s.Bullets {
fmt.Fprintf(&sb, "- %s\n", b)
}
sb.WriteByte('\n')
}
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
}
func (e *Executor) editPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "edit_presentation: %s not found", path)
}
slide := parseSlideArg(call.Arguments)
switch argStr(call.Arguments, "operation") {
case "add_slide":
out, err := officekit.AppendPptxSlide(data, slide)
if err != nil {
return errResult(call.Name, "edit_presentation: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("added 1 slide to %s", path)}
case "update_slide":
idx := argInt(call.Arguments, "index")
out, err := officekit.UpdatePptxSlide(data, idx, slide)
if err != nil {
return errResult(call.Name, "edit_presentation: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("updated slide %d of %s", idx, path)}
default:
return errResult(call.Name, "edit_presentation: unknown operation %q (expected add_slide or update_slide)", argStr(call.Arguments, "operation"))
}
}
// --- PDF (read-only: data extraction, not creation) --------------------
func (e *Executor) readPdf(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_pdf: %s not found or empty", path)
}
text, err := officekit.ReadPdfText(data)
if err != nil {
return errResult(call.Name, "read_pdf: %v", err)
}
return vllmkit.ToolResult{Name: call.Name, Content: text}
}