feat: list_results + read_result tools for coordinator artifact discovery
This commit is contained in:
@@ -300,6 +300,8 @@ func main() {
|
||||
batchTool := agent.NewBatch(elfMgr, store)
|
||||
batchTool.SetProgressCh(elfProgressCh)
|
||||
reg.Register(batchTool)
|
||||
reg.Register(agent.NewListResultsTool(store))
|
||||
reg.Register(agent.NewReadResultTool(store))
|
||||
|
||||
// Build system prompt with cwd + compact inventory summary
|
||||
systemPrompt := *system
|
||||
|
||||
94
internal/tool/agent/coordinator_test.go
Normal file
94
internal/tool/agent/coordinator_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package agent_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/agent"
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/persist"
|
||||
)
|
||||
|
||||
func makeTestStore(t *testing.T) *persist.Store {
|
||||
t.Helper()
|
||||
s := persist.New("test-coord-" + t.Name())
|
||||
t.Cleanup(func() { os.RemoveAll(s.Dir()) })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestListResultsTool_EmptyStore(t *testing.T) {
|
||||
s := makeTestStore(t)
|
||||
tool := agent.NewListResultsTool(s)
|
||||
args, _ := json.Marshal(map[string]string{})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = result // empty or "no results" — either is fine
|
||||
}
|
||||
|
||||
func TestListResultsTool_ListsFiles(t *testing.T) {
|
||||
s := makeTestStore(t)
|
||||
big := strings.Repeat("x", 1024)
|
||||
s.Save("bash", "toolu_aaa", big)
|
||||
s.Save("fs.grep", "toolu_bbb", big)
|
||||
|
||||
tool := agent.NewListResultsTool(s)
|
||||
args, _ := json.Marshal(map[string]string{})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Output, "bash") {
|
||||
t.Errorf("expected bash in output, got: %s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "fs") {
|
||||
t.Errorf("expected fs in output, got: %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResultsTool_FilterByToolName(t *testing.T) {
|
||||
s := makeTestStore(t)
|
||||
big := strings.Repeat("x", 1024)
|
||||
s.Save("bash", "toolu_c1", big)
|
||||
s.Save("fs.read", "toolu_c2", big)
|
||||
|
||||
tool := agent.NewListResultsTool(s)
|
||||
args, _ := json.Marshal(map[string]string{"filter": "bash"})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(result.Output, "fs") {
|
||||
t.Errorf("filter should exclude fs.read, got: %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadResultTool_ReadsFile(t *testing.T) {
|
||||
s := makeTestStore(t)
|
||||
big := strings.Repeat("hello\n", 200)
|
||||
path, _ := s.Save("bash", "toolu_read1", big)
|
||||
|
||||
tool := agent.NewReadResultTool(s)
|
||||
args, _ := json.Marshal(map[string]string{"path": path})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Output, "hello") {
|
||||
t.Errorf("expected file content in output, got: %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadResultTool_RejectsPathTraversal(t *testing.T) {
|
||||
s := makeTestStore(t)
|
||||
tool := agent.NewReadResultTool(s)
|
||||
args, _ := json.Marshal(map[string]string{"path": "/etc/passwd"})
|
||||
// Path traversal: tool must reject this — either err != nil or output contains rejection message
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err == nil && !strings.Contains(result.Output, "outside") && !strings.Contains(result.Output, "permission") && !strings.Contains(result.Output, "error") {
|
||||
t.Errorf("expected path traversal rejection, got: output=%q err=%v", result.Output, err)
|
||||
}
|
||||
}
|
||||
76
internal/tool/agent/list_results.go
Normal file
76
internal/tool/agent/list_results.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool"
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/persist"
|
||||
)
|
||||
|
||||
var listResultsSchema = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "Optional tool name prefix to filter results (e.g. 'bash' shows only bash results). Note: dots in tool names become underscores (e.g. 'fs_grep' for 'fs.grep')."
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
type ListResultsTool struct {
|
||||
store *persist.Store
|
||||
}
|
||||
|
||||
func NewListResultsTool(s *persist.Store) *ListResultsTool {
|
||||
return &ListResultsTool{store: s}
|
||||
}
|
||||
|
||||
func (t *ListResultsTool) Name() string { return "list_results" }
|
||||
func (t *ListResultsTool) Description() string {
|
||||
return "List tool result files saved in this session. Use this to discover outputs from previous tool calls that can be passed to elfs or read with read_result."
|
||||
}
|
||||
func (t *ListResultsTool) Parameters() json.RawMessage { return listResultsSchema }
|
||||
func (t *ListResultsTool) IsReadOnly() bool { return true }
|
||||
func (t *ListResultsTool) IsDestructive() bool { return false }
|
||||
|
||||
type listResultsArgs struct {
|
||||
Filter string `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
func (t *ListResultsTool) Execute(_ context.Context, args json.RawMessage) (tool.Result, error) {
|
||||
var a listResultsArgs
|
||||
json.Unmarshal(args, &a) //nolint:errcheck
|
||||
|
||||
files, err := t.store.List(a.Filter)
|
||||
if err != nil {
|
||||
return tool.Result{Output: fmt.Sprintf("error listing results: %v", err)}, nil
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return tool.Result{Output: "no results persisted in this session yet"}, nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d result(s) in session:\n\n", len(files))
|
||||
for _, f := range files {
|
||||
fmt.Fprintf(&b, "%s [%s, %s, %s]\n",
|
||||
f.Path,
|
||||
f.ToolName,
|
||||
formatSize(f.Size),
|
||||
f.ModTime.Format("15:04:05"),
|
||||
)
|
||||
}
|
||||
return tool.Result{Output: b.String()}, nil
|
||||
}
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
if bytes >= 1024*1024 {
|
||||
return fmt.Sprintf("%.1fMB", float64(bytes)/1024/1024)
|
||||
}
|
||||
if bytes >= 1024 {
|
||||
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%dB", bytes)
|
||||
}
|
||||
57
internal/tool/agent/read_result.go
Normal file
57
internal/tool/agent/read_result.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool"
|
||||
"somegit.dev/Owlibou/gnoma/internal/tool/persist"
|
||||
)
|
||||
|
||||
var readResultSchema = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the result file (from list_results output)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}`)
|
||||
|
||||
type ReadResultTool struct {
|
||||
store *persist.Store
|
||||
}
|
||||
|
||||
func NewReadResultTool(s *persist.Store) *ReadResultTool {
|
||||
return &ReadResultTool{store: s}
|
||||
}
|
||||
|
||||
func (t *ReadResultTool) Name() string { return "read_result" }
|
||||
func (t *ReadResultTool) Description() string {
|
||||
return "Read the full content of a persisted tool result file. Use paths from list_results. Only files within the current session directory are accessible."
|
||||
}
|
||||
func (t *ReadResultTool) Parameters() json.RawMessage { return readResultSchema }
|
||||
func (t *ReadResultTool) IsReadOnly() bool { return true }
|
||||
func (t *ReadResultTool) IsDestructive() bool { return false }
|
||||
|
||||
type readResultArgs struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (t *ReadResultTool) Execute(_ context.Context, args json.RawMessage) (tool.Result, error) {
|
||||
var a readResultArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return tool.Result{}, fmt.Errorf("read_result: invalid args: %w", err)
|
||||
}
|
||||
if a.Path == "" {
|
||||
return tool.Result{}, fmt.Errorf("read_result: path required")
|
||||
}
|
||||
|
||||
content, err := t.store.Read(a.Path)
|
||||
if err != nil {
|
||||
return tool.Result{Output: fmt.Sprintf("error reading result: %v", err)}, nil
|
||||
}
|
||||
return tool.Result{Output: content}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user