Clouade did up to this point

This commit is contained in:
2026-04-17 15:24:21 +03:00
parent f4b1ac5789
commit b1aba60817
7 changed files with 416 additions and 47 deletions

116
tools.go
View File

@@ -1,12 +1,16 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type Tool struct {
@@ -20,7 +24,52 @@ type ToolFunction struct {
Parameters map[string]any `json:"parameters"`
}
func toolDefinitions() []Tool {
// programmerTools returns the full file toolset for the Programmer agent.
func programmerTools() []Tool { return allFileTools() }
// qaTools returns read-only file tools plus a shell command runner so QA can
// execute test suites / linters / builds for any language or framework.
func qaTools() []Tool {
keep := map[string]bool{"read_file": true, "list_directory": true}
all := allFileTools()
out := make([]Tool, 0, len(keep)+1)
for _, t := range all {
if keep[t.Function.Name] {
out = append(out, t)
}
}
out = append(out, commandTool())
return out
}
func commandTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "run_command",
Description: "Run a shell command via 'sh -c' in the project root and return combined stdout+stderr plus exit code. " +
"Use for running test suites, linters, type-checkers, or builds — any language or framework. " +
"Examples: 'go test ./...', 'bun test', 'npm run lint', 'php artisan test', 'composer test', 'pytest -q', 'cargo test'. " +
"Inherits the caller's PATH and environment. The working directory starts at the project root; note that the shell itself is not path-sandboxed, so prefer commands that stay within the project.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"description": "Full shell command to execute (pipes, redirects, env-prefixes supported).",
},
"timeout_seconds": map[string]any{
"type": "integer",
"description": "Max seconds to wait. Default 60, capped at 600.",
},
},
"required": []string{"command"},
},
},
}
}
func allFileTools() []Tool {
return []Tool{
{
Type: "function",
@@ -104,8 +153,10 @@ func toolDefinitions() []Tool {
// Errors are returned as strings too so the model can react, not fatal.
func runTool(root, name, argsJSON string) string {
var a struct {
Path string `json:"path"`
Content string `json:"content"`
Path string `json:"path"`
Content string `json:"content"`
Command string `json:"command"`
TimeoutSeconds int `json:"timeout_seconds"`
}
if argsJSON != "" {
if err := json.Unmarshal([]byte(argsJSON), &a); err != nil {
@@ -124,6 +175,8 @@ func runTool(root, name, argsJSON string) string {
return doDelete(root, a.Path)
case "list_directory":
return doList(root, a.Path)
case "run_command":
return doRunCommand(root, a.Command, a.TimeoutSeconds)
default:
return fmt.Sprintf("error: unknown tool %q", name)
}
@@ -197,6 +250,63 @@ func doDelete(root, p string) string {
return fmt.Sprintf("deleted %s", p)
}
const (
defaultCommandTimeout = 60
maxCommandTimeout = 600
maxCommandOutput = 16 * 1024
)
func doRunCommand(root, command string, timeoutSec int) string {
if strings.TrimSpace(command) == "" {
return "error: empty command"
}
if timeoutSec <= 0 {
timeoutSec = defaultCommandTimeout
}
if timeoutSec > maxCommandTimeout {
timeoutSec = maxCommandTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", command)
cmd.Dir = root
cmd.Env = os.Environ()
start := time.Now()
out, runErr := cmd.CombinedOutput()
elapsed := time.Since(start).Round(time.Millisecond)
truncated := false
if len(out) > maxCommandOutput {
out = out[:maxCommandOutput]
truncated = true
}
var header string
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
header = fmt.Sprintf("status=timeout after %ds elapsed=%s", timeoutSec, elapsed)
case runErr != nil:
var ee *exec.ExitError
if errors.As(runErr, &ee) {
header = fmt.Sprintf("exit_code=%d elapsed=%s", ee.ExitCode(), elapsed)
} else {
header = fmt.Sprintf("status=error: %v elapsed=%s", runErr, elapsed)
}
default:
header = fmt.Sprintf("exit_code=0 elapsed=%s", elapsed)
}
suffix := ""
if truncated {
suffix = fmt.Sprintf("\n[output truncated at %d bytes]", maxCommandOutput)
}
return fmt.Sprintf("$ %s\n%s\n--- output ---\n%s%s", command, header, string(out), suffix)
}
func doList(root, p string) string {
if p == "" {
p = "."