Skip to content

Commit 2889b7f

Browse files
authored
feat: Add Resources auto-archive for generated Office files (#2311)
1 parent eccc6eb commit 2889b7f

7 files changed

Lines changed: 194 additions & 9 deletions

File tree

object/merge_agent_tools.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ func buildMergedBuiltinRegistry(store *Store, lang string) *tool.ToolRegistry {
5555
continue
5656
}
5757
for _, bt := range tp.BuiltinTools() {
58-
reg.RegisterTool(wrapSnapshotBuiltin(store.Owner, bt))
58+
wrapped := wrapSnapshotBuiltin(store.Owner, bt)
59+
wrapped = wrapGeneratedResourceBuiltin(wrapped)
60+
reg.RegisterTool(wrapped)
5961
}
6062
}
6163

object/message_tool.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ func buildToolSetForBuiltinTool(toolName string, lang string) (*mcp.ToolSet, err
4444

4545
reg := tool.NewToolRegistry()
4646
for _, t := range tp.BuiltinTools() {
47-
reg.RegisterTool(wrapSnapshotBuiltin("admin", t))
47+
wrapped := wrapSnapshotBuiltin("admin", t)
48+
wrapped = wrapGeneratedResourceBuiltin(wrapped)
49+
reg.RegisterTool(wrapped)
4850
}
4951

5052
allTools := reg.GetToolsAsProtocolTools()

object/resource_archive_tool.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package object
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"mime"
21+
"os"
22+
"path/filepath"
23+
"strings"
24+
25+
"github.com/ThinkInAIXYZ/go-mcp/protocol"
26+
"github.com/the-open-agent/openagent/tool"
27+
"github.com/the-open-agent/openagent/util"
28+
)
29+
30+
type generatedResourceArchiveBuiltinTool struct {
31+
inner tool.BuiltinTool
32+
}
33+
34+
var archiveGeneratedResourceFile = archiveGeneratedResourceFileToStorage
35+
36+
func wrapGeneratedResourceBuiltin(builtin tool.BuiltinTool) tool.BuiltinTool {
37+
if builtin == nil {
38+
return nil
39+
}
40+
if !isGeneratedResourceTool(builtin.GetName()) {
41+
return builtin
42+
}
43+
return &generatedResourceArchiveBuiltinTool{inner: builtin}
44+
}
45+
46+
func isGeneratedResourceTool(toolName string) bool {
47+
switch toolName {
48+
case "pptx_write", "word_write", "excel_write":
49+
return true
50+
default:
51+
return false
52+
}
53+
}
54+
55+
func (t *generatedResourceArchiveBuiltinTool) GetName() string {
56+
return t.inner.GetName()
57+
}
58+
59+
func (t *generatedResourceArchiveBuiltinTool) GetDescription() string {
60+
return t.inner.GetDescription()
61+
}
62+
63+
func (t *generatedResourceArchiveBuiltinTool) GetInputSchema() interface{} {
64+
return t.inner.GetInputSchema()
65+
}
66+
67+
func (t *generatedResourceArchiveBuiltinTool) Execute(ctx context.Context, arguments map[string]interface{}) (*protocol.CallToolResult, error) {
68+
result, innerErr := t.inner.Execute(ctx, arguments)
69+
if innerErr != nil || result == nil || result.IsError {
70+
return result, innerErr
71+
}
72+
73+
path, ok := generatedResourceTargetPath(t.GetName(), arguments)
74+
if !ok {
75+
return result, innerErr
76+
}
77+
78+
resource, err := archiveGeneratedResourceFile(path)
79+
if err != nil {
80+
appendGeneratedResourceArchiveText(result, fmt.Sprintf("Resource archive warning: file was created but could not be saved to Resources: %s", err.Error()))
81+
return result, innerErr
82+
}
83+
if resource == nil {
84+
appendGeneratedResourceArchiveText(result, "Resource archive warning: file was created but no Resource record was returned")
85+
return result, innerErr
86+
}
87+
88+
appendGeneratedResourceArchiveText(result, fmt.Sprintf("Saved to Resources: %s", resource.Url))
89+
return result, innerErr
90+
}
91+
92+
func generatedResourceTargetPath(toolName string, arguments map[string]interface{}) (string, bool) {
93+
switch toolName {
94+
case "pptx_write", "word_write", "excel_write":
95+
path := resourceArchiveStringArg(arguments, "path")
96+
if path == "" {
97+
return "", false
98+
}
99+
return filepath.Clean(tool.ResolveOutputPath(path)), true
100+
default:
101+
return "", false
102+
}
103+
}
104+
105+
func resourceArchiveStringArg(arguments map[string]interface{}, key string) string {
106+
value, _ := arguments[key].(string)
107+
return strings.TrimSpace(value)
108+
}
109+
110+
func archiveGeneratedResourceFileToStorage(path string) (*Resource, error) {
111+
info, err := os.Stat(path)
112+
if err != nil {
113+
return nil, err
114+
}
115+
if info.IsDir() {
116+
return nil, fmt.Errorf("path is a directory: %s", path)
117+
}
118+
119+
fileBytes, err := os.ReadFile(path)
120+
if err != nil {
121+
return nil, err
122+
}
123+
124+
fileName := filepath.Base(path)
125+
if fileName == "." || fileName == string(os.PathSeparator) || fileName == "" {
126+
fileName = "generated_file"
127+
}
128+
ext := strings.ToLower(filepath.Ext(fileName))
129+
fileType := getGeneratedResourceFileType(ext)
130+
storageName := fmt.Sprintf(
131+
"openagent/resources/generated/%s_%s_%s",
132+
resourceArchiveSafePathSegment(util.GetCurrentTime()),
133+
util.GetRandomName(),
134+
resourceArchiveSafePathSegment(fileName),
135+
)
136+
137+
fileUrl, err := UploadFileToStorageSafe(storageName, fileBytes, "", "")
138+
if err != nil {
139+
return nil, err
140+
}
141+
142+
resource := NewResourceFromUpload("admin", "", "generated", fileName, fileType, ext, fileUrl, storageName, len(fileBytes), "", "")
143+
if _, err = AddResource(resource); err != nil {
144+
return nil, err
145+
}
146+
return resource, nil
147+
}
148+
149+
func getGeneratedResourceFileType(ext string) string {
150+
mimeType := mime.TypeByExtension(ext)
151+
if mimeType == "" {
152+
return "unknown"
153+
}
154+
parts := strings.SplitN(mimeType, "/", 2)
155+
if len(parts) == 0 || parts[0] == "" {
156+
return "unknown"
157+
}
158+
return parts[0]
159+
}
160+
161+
func resourceArchiveSafePathSegment(value string) string {
162+
value = strings.TrimSpace(value)
163+
if value == "" {
164+
return "unknown"
165+
}
166+
return strings.Map(func(r rune) rune {
167+
switch r {
168+
case '/', '\\', 0:
169+
return '_'
170+
default:
171+
return r
172+
}
173+
}, value)
174+
}
175+
176+
func appendGeneratedResourceArchiveText(result *protocol.CallToolResult, text string) {
177+
if result == nil || text == "" {
178+
return
179+
}
180+
result.Content = append(result.Content, &protocol.TextContent{Type: "text", Text: text})
181+
}

tool/office.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ import (
2121
"github.com/ThinkInAIXYZ/go-mcp/protocol"
2222
)
2323

24-
// resolveOutputPath returns path unchanged if it is absolute.
24+
// ResolveOutputPath returns path unchanged if it is absolute.
2525
// For relative paths it resolves them against the current user's Documents
2626
// folder so that files created by the AI land in a predictable, user-visible
2727
// location rather than in the server's working directory.
2828
//
2929
// Resolution order:
3030
// 1. XDG_DOCUMENTS_DIR environment variable (Linux / freedesktop standard)
3131
// 2. ~/Documents as a cross-platform fallback (Windows, macOS, Linux)
32-
func resolveOutputPath(path string) string {
32+
func ResolveOutputPath(path string) string {
3333
if filepath.IsAbs(path) {
3434
return path
3535
}

tool/office_excel.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func (t *excelWriteBuiltin) Execute(_ context.Context, arguments map[string]inte
121121
sheetName = "Sheet1"
122122
}
123123

124-
resolvedPath := resolveOutputPath(path)
124+
resolvedPath := ResolveOutputPath(path)
125125
rowCount, colCount, err := writeExcelFile(path, sheetName, data)
126126
if err != nil {
127127
return officeToolError(fmt.Sprintf("Failed to write Excel file: %s", err.Error())), nil
@@ -201,7 +201,7 @@ func readExcelFile(path, sheetName string) (string, error) {
201201
// writeExcelFile creates (or overwrites) an xlsx file from CSV-formatted text.
202202
// It returns the number of rows and the maximum column count written.
203203
func writeExcelFile(path, sheetName, csvData string) (rowCount, colCount int, err error) {
204-
path = resolveOutputPath(path)
204+
path = ResolveOutputPath(path)
205205
dir := filepath.Dir(path)
206206
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
207207
return 0, 0, fmt.Errorf("failed to create directory %q: %w", dir, mkErr)

tool/office_ppt_write.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ func (t *pptxWriteBuiltin) Execute(ctx context.Context, arguments map[string]int
153153
return officeToolError(err.Error()), nil
154154
}
155155

156-
args.Path = resolveOutputPath(args.Path)
156+
args.Path = ResolveOutputPath(args.Path)
157157

158158
// Pass the job to Node through a temp JSON file so nested data does not
159159
// need fragile command-line escaping. The final PPTX is not temporary.

tool/office_word.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func (t *wordWriteBuiltin) Execute(_ context.Context, arguments map[string]inter
104104
return officeToolError("Missing required parameter: content"), nil
105105
}
106106

107-
resolvedPath := resolveOutputPath(path)
107+
resolvedPath := ResolveOutputPath(path)
108108
if err := writeWordFile(path, content); err != nil {
109109
return officeToolError(fmt.Sprintf("Failed to write Word file: %s", err.Error())), nil
110110
}
@@ -131,7 +131,7 @@ func readWordFile(path string) (string, error) {
131131
}
132132

133133
func writeWordFile(path string, content string) error {
134-
path = resolveOutputPath(path)
134+
path = ResolveOutputPath(path)
135135
dir := filepath.Dir(path)
136136
if err := os.MkdirAll(dir, 0o755); err != nil {
137137
return fmt.Errorf("failed to create directory %q: %w", dir, err)

0 commit comments

Comments
 (0)