diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 2ea81c6..4c82535 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -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 diff --git a/internal/tool/agent/coordinator_test.go b/internal/tool/agent/coordinator_test.go new file mode 100644 index 0000000..93c0dd4 --- /dev/null +++ b/internal/tool/agent/coordinator_test.go @@ -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) + } +} diff --git a/internal/tool/agent/list_results.go b/internal/tool/agent/list_results.go new file mode 100644 index 0000000..476885a --- /dev/null +++ b/internal/tool/agent/list_results.go @@ -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) +} diff --git a/internal/tool/agent/read_result.go b/internal/tool/agent/read_result.go new file mode 100644 index 0000000..1902764 --- /dev/null +++ b/internal/tool/agent/read_result.go @@ -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 +}