Clouade did up to this point
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
dist/
|
dist/
|
||||||
.config.json
|
.config.json
|
||||||
|
sandbox/
|
||||||
59
agent.go
Normal file
59
agent.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ToolExec func(ctx context.Context, name, argsJSON string) string
|
||||||
|
|
||||||
|
const maxAgentIterations = 50
|
||||||
|
|
||||||
|
// Agent is a stateless driver: given a message slice and tools, it advances
|
||||||
|
// the conversation until the assistant replies with no tool calls.
|
||||||
|
type Agent struct {
|
||||||
|
Name string
|
||||||
|
Client *Client
|
||||||
|
SystemPrompt string
|
||||||
|
Tools []Tool
|
||||||
|
ToolExec ToolExec
|
||||||
|
OnToolCall func(agent string, tc ToolCall, result string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run advances the given messages, returning the final assistant text and
|
||||||
|
// the updated message slice (including tool calls + tool results).
|
||||||
|
func (a *Agent) Run(ctx context.Context, messages []Message) (string, []Message, error) {
|
||||||
|
for i := 0; i < maxAgentIterations; i++ {
|
||||||
|
msg, err := a.Client.Chat(ctx, messages, a.Tools)
|
||||||
|
if err != nil {
|
||||||
|
return "", messages, err
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
if len(msg.ToolCalls) == 0 {
|
||||||
|
return msg.Content, messages, nil
|
||||||
|
}
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
result := a.ToolExec(ctx, tc.Function.Name, tc.Function.Arguments)
|
||||||
|
if a.OnToolCall != nil {
|
||||||
|
a.OnToolCall(a.Name, tc, result)
|
||||||
|
}
|
||||||
|
messages = append(messages, Message{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Content: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", messages, fmt.Errorf("%s: exceeded %d iterations without final reply", a.Name, maxAgentIterations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do is a one-shot helper: fresh conversation of system+user → final text.
|
||||||
|
func (a *Agent) Do(ctx context.Context, userMsg string) (string, error) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "system", Content: a.SystemPrompt},
|
||||||
|
{Role: "user", Content: userMsg},
|
||||||
|
}
|
||||||
|
reply, _, err := a.Run(ctx, messages)
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
53
main.go
53
main.go
@@ -8,11 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemPrompt = `You are a coding assistant running inside a CLI. You have file tools scoped
|
|
||||||
to the current project directory. All paths are relative to the project root; absolute paths
|
|
||||||
and paths that escape the root (via "..") will be rejected. Prefer listing and reading before
|
|
||||||
editing. Keep replies concise.`
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
root, err := os.Getwd()
|
root, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,12 +30,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := NewClient(baseURL, apiKey, model)
|
client := NewClient(baseURL, apiKey, model)
|
||||||
tools := toolDefinitions()
|
orch := NewOrchestrator(client, root)
|
||||||
|
|
||||||
messages := []Message{{Role: "system", Content: systemPrompt}}
|
fmt.Printf("NyxTex agent (manager + programmer + qa) — model=%s root=%s\n", model, root)
|
||||||
|
fmt.Println("Type your request. Ctrl+D or /exit to quit.")
|
||||||
fmt.Printf("NyxTex agent — model=%s root=%s\n", model, root)
|
|
||||||
fmt.Println("Type your request. Empty line to submit. Ctrl+D or /exit to quit.")
|
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
for {
|
for {
|
||||||
@@ -58,10 +51,14 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, Message{Role: "user", Content: input})
|
reply, err := orch.Handle(context.Background(), input)
|
||||||
|
if err != nil {
|
||||||
if err := runTurn(context.Background(), client, tools, root, &messages); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "error:", err)
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(reply) != "" {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,36 +71,6 @@ func readUserInput(r *bufio.Reader) (string, error) {
|
|||||||
return line, nil
|
return line, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runTurn sends the conversation and handles any tool-call loop until the
|
|
||||||
// assistant produces a plain text reply.
|
|
||||||
func runTurn(ctx context.Context, client *Client, tools []Tool, root string, messages *[]Message) error {
|
|
||||||
for {
|
|
||||||
msg, err := client.Chat(ctx, *messages, tools)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*messages = append(*messages, msg)
|
|
||||||
|
|
||||||
if len(msg.ToolCalls) == 0 {
|
|
||||||
if strings.TrimSpace(msg.Content) != "" {
|
|
||||||
fmt.Println(msg.Content)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range msg.ToolCalls {
|
|
||||||
fmt.Printf(" [tool] %s %s\n", tc.Function.Name, tc.Function.Arguments)
|
|
||||||
result := runTool(root, tc.Function.Name, tc.Function.Arguments)
|
|
||||||
*messages = append(*messages, Message{
|
|
||||||
Role: "tool",
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Content: result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvDefault(key, def string) string {
|
func getEnvDefault(key, def string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
32
openai.go
32
openai.go
@@ -7,6 +7,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,19 +60,38 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(baseURL, apiKey, model string) *Client {
|
func NewClient(baseURL, apiKey, model string) *Client {
|
||||||
|
timeout := 600 * time.Second
|
||||||
|
if v := os.Getenv("OPENAI_TIMEOUT_SECONDS"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
timeout = time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
Model: model,
|
Model: model,
|
||||||
HTTP: &http.Client{Timeout: 120 * time.Second},
|
HTTP: &http.Client{Timeout: timeout},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var aiCallCounter uint64
|
||||||
|
|
||||||
|
func logAIIO() bool { return os.Getenv("LOG_AI_IO") == "1" }
|
||||||
|
|
||||||
func (c *Client) Chat(ctx context.Context, messages []Message, tools []Tool) (Message, error) {
|
func (c *Client) Chat(ctx context.Context, messages []Message, tools []Tool) (Message, error) {
|
||||||
body, err := json.Marshal(chatRequest{Model: c.Model, Messages: messages, Tools: tools})
|
body, err := json.Marshal(chatRequest{Model: c.Model, Messages: messages, Tools: tools})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
callID := atomic.AddUint64(&aiCallCounter, 1)
|
||||||
|
debug := logAIIO()
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n===== AI REQUEST #%d model=%s msgs=%d tools=%d =====\n%s\n",
|
||||||
|
callID, c.Model, len(messages), len(tools), string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/chat/completions", bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
@@ -80,6 +102,10 @@ func (c *Client) Chat(ctx context.Context, messages []Message, tools []Tool) (Me
|
|||||||
}
|
}
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "===== AI REQUEST #%d FAILED after %s: %v =====\n",
|
||||||
|
callID, time.Since(start).Round(time.Millisecond), err)
|
||||||
|
}
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -88,6 +114,10 @@ func (c *Client) Chat(ctx context.Context, messages []Message, tools []Tool) (Me
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "===== AI RESPONSE #%d status=%d elapsed=%s bytes=%d =====\n%s\n",
|
||||||
|
callID, resp.StatusCode, time.Since(start).Round(time.Millisecond), len(raw), string(raw))
|
||||||
|
}
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return Message{}, fmt.Errorf("api error %d: %s", resp.StatusCode, string(raw))
|
return Message{}, fmt.Errorf("api error %d: %s", resp.StatusCode, string(raw))
|
||||||
}
|
}
|
||||||
|
|||||||
150
orchestrator.go
Normal file
150
orchestrator.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const managerSystemPrompt = `You are the Manager agent. You coordinate between the user, a Programmer agent, and a QA agent.
|
||||||
|
|
||||||
|
You have two orchestration tools:
|
||||||
|
- assign_task(instructions): delegate coding work to the Programmer. Returns the Programmer's report.
|
||||||
|
- request_qa_review(focus): ask QA to validate specific files/changes. Returns QA's report.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Read the user's request. If it is purely conversational or meta (a greeting, a question about the agent itself), answer directly with no tool calls.
|
||||||
|
2. Otherwise, plan the work, then call assign_task with clear, self-contained instructions for the Programmer.
|
||||||
|
3. When the Programmer reports back, call request_qa_review naming the files touched and what QA should verify.
|
||||||
|
4. If QA reports issues, call assign_task again with specific fix instructions that reference QA's findings.
|
||||||
|
5. Iterate until QA approves, then reply to the user with a concise summary of what changed and any caveats.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- You have NO file tools. Do not try to read or edit files yourself.
|
||||||
|
- Every call to assign_task/request_qa_review spawns a fresh sub-agent with no memory of prior calls — put all needed context into the brief.
|
||||||
|
- Keep the final user-facing reply brief: what was done, where, and anything flagged by QA.`
|
||||||
|
|
||||||
|
func managerTools() []Tool {
|
||||||
|
return []Tool{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunction{
|
||||||
|
Name: "assign_task",
|
||||||
|
Description: "Delegate a coding task to the Programmer agent. The Programmer has read/create/edit/delete/list file tools scoped to the project root. Returns the Programmer's written report.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"instructions": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Clear, self-contained instructions for the Programmer. Include all context needed (files, goals, constraints).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"instructions"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunction{
|
||||||
|
Name: "request_qa_review",
|
||||||
|
Description: "Ask the QA agent to review code. QA has read-only file tools (read_file, list_directory) plus a shell command runner (run_command) that can execute any test suite, linter, type-checker, or build command. Returns QA's written report with a verdict.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"focus": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "What QA should review: specific files, what aspect of the change, what to look for. Be explicit.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"focus"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Orchestrator struct {
|
||||||
|
client *Client
|
||||||
|
root string
|
||||||
|
manager *Agent
|
||||||
|
history []Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrchestrator(client *Client, root string) *Orchestrator {
|
||||||
|
o := &Orchestrator{client: client, root: root}
|
||||||
|
o.manager = &Agent{
|
||||||
|
Name: "manager",
|
||||||
|
Client: client,
|
||||||
|
SystemPrompt: managerSystemPrompt,
|
||||||
|
Tools: managerTools(),
|
||||||
|
ToolExec: o.execManagerTool,
|
||||||
|
OnToolCall: logToolCall(""),
|
||||||
|
}
|
||||||
|
o.history = []Message{{Role: "system", Content: managerSystemPrompt}}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle advances the Manager conversation with one user turn.
|
||||||
|
func (o *Orchestrator) Handle(ctx context.Context, userInput string) (string, error) {
|
||||||
|
o.history = append(o.history, Message{Role: "user", Content: userInput})
|
||||||
|
reply, updated, err := o.manager.Run(ctx, o.history)
|
||||||
|
o.history = updated
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) execManagerTool(ctx context.Context, name, argsJSON string) string {
|
||||||
|
switch name {
|
||||||
|
case "assign_task":
|
||||||
|
var a struct {
|
||||||
|
Instructions string `json:"instructions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(argsJSON), &a); err != nil {
|
||||||
|
return "error: invalid arguments: " + err.Error()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(a.Instructions) == "" {
|
||||||
|
return "error: instructions is required"
|
||||||
|
}
|
||||||
|
prog := newProgrammerAgent(o.client, o.root, logToolCall(" "))
|
||||||
|
report, err := prog.Do(ctx, a.Instructions)
|
||||||
|
if err != nil {
|
||||||
|
return "error: programmer failed: " + err.Error()
|
||||||
|
}
|
||||||
|
printReport("programmer", report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
case "request_qa_review":
|
||||||
|
var a struct {
|
||||||
|
Focus string `json:"focus"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(argsJSON), &a); err != nil {
|
||||||
|
return "error: invalid arguments: " + err.Error()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(a.Focus) == "" {
|
||||||
|
return "error: focus is required"
|
||||||
|
}
|
||||||
|
qa := newQAAgent(o.client, o.root, logToolCall(" "))
|
||||||
|
report, err := qa.Do(ctx, a.Focus)
|
||||||
|
if err != nil {
|
||||||
|
return "error: qa failed: " + err.Error()
|
||||||
|
}
|
||||||
|
printReport("qa", report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "error: unknown tool " + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logToolCall(indent string) func(string, ToolCall, string) {
|
||||||
|
return func(agent string, tc ToolCall, _ string) {
|
||||||
|
fmt.Printf("%s[%s] %s %s\n", indent, agent, tc.Function.Name, tc.Function.Arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printReport(from, report string) {
|
||||||
|
fmt.Printf(" [%s → manager]\n", from)
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(report, "\n"), "\n") {
|
||||||
|
fmt.Printf(" %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
roles.go
Normal file
52
roles.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
const programmerSystemPrompt = `You are the Programmer agent.
|
||||||
|
A Manager agent delegates coding tasks to you. Execute them using your file tools.
|
||||||
|
All paths are relative to the project root; absolute paths and parent escapes ("..") are rejected.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Read relevant files before editing.
|
||||||
|
- Make minimal, focused changes that fulfill the task. Do not refactor unrelated code or add speculative features.
|
||||||
|
- When done, reply with a concise report listing each file you changed and a one-line summary of the change.
|
||||||
|
- If you cannot complete the task, report the blocker honestly rather than inventing a workaround.`
|
||||||
|
|
||||||
|
const qaSystemPrompt = `You are the QA/Tester agent.
|
||||||
|
A Manager agent asks you to validate code changes. You have:
|
||||||
|
- read-only file tools: read_file, list_directory (scoped to the project root)
|
||||||
|
- a shell command runner: run_command (executes via 'sh -c' with cwd at the project root)
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Inspect the files called out in the focus brief, plus surrounding and related code that might be affected.
|
||||||
|
- Check for: correctness vs. the stated task, missing edge cases, broken references, inconsistencies with the rest of the codebase, regressions.
|
||||||
|
- Use run_command to execute the project's tests / linters / type-checkers / builds. Pick commands appropriate to the stack you detect (e.g., look for go.mod → 'go test ./...'; package.json → 'npm test' or 'bun test'; composer.json / artisan → 'php artisan test' or 'vendor/bin/phpunit'; pyproject.toml → 'pytest -q'; Cargo.toml → 'cargo test').
|
||||||
|
- If no test suite exists, at minimum run a build/type-check (e.g., 'go build ./...', 'tsc --noEmit', 'php -l <files>').
|
||||||
|
- Do not modify files.
|
||||||
|
- Reply with a concise report: what you checked, what commands you ran and their results, what looks correct, and any issues. Cite file:line where possible. End with a clear verdict: "approved" or "needs changes".`
|
||||||
|
|
||||||
|
func newProgrammerAgent(client *Client, root string, log func(string, ToolCall, string)) *Agent {
|
||||||
|
return &Agent{
|
||||||
|
Name: "programmer",
|
||||||
|
Client: client,
|
||||||
|
SystemPrompt: programmerSystemPrompt,
|
||||||
|
Tools: programmerTools(),
|
||||||
|
ToolExec: func(_ context.Context, name, args string) string {
|
||||||
|
return runTool(root, name, args)
|
||||||
|
},
|
||||||
|
OnToolCall: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQAAgent(client *Client, root string, log func(string, ToolCall, string)) *Agent {
|
||||||
|
return &Agent{
|
||||||
|
Name: "qa",
|
||||||
|
Client: client,
|
||||||
|
SystemPrompt: qaSystemPrompt,
|
||||||
|
Tools: qaTools(),
|
||||||
|
ToolExec: func(_ context.Context, name, args string) string {
|
||||||
|
return runTool(root, name, args)
|
||||||
|
},
|
||||||
|
OnToolCall: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
116
tools.go
116
tools.go
@@ -1,12 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
@@ -20,7 +24,52 @@ type ToolFunction struct {
|
|||||||
Parameters map[string]any `json:"parameters"`
|
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{
|
return []Tool{
|
||||||
{
|
{
|
||||||
Type: "function",
|
Type: "function",
|
||||||
@@ -104,8 +153,10 @@ func toolDefinitions() []Tool {
|
|||||||
// Errors are returned as strings too so the model can react, not fatal.
|
// Errors are returned as strings too so the model can react, not fatal.
|
||||||
func runTool(root, name, argsJSON string) string {
|
func runTool(root, name, argsJSON string) string {
|
||||||
var a struct {
|
var a struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
}
|
}
|
||||||
if argsJSON != "" {
|
if argsJSON != "" {
|
||||||
if err := json.Unmarshal([]byte(argsJSON), &a); err != nil {
|
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)
|
return doDelete(root, a.Path)
|
||||||
case "list_directory":
|
case "list_directory":
|
||||||
return doList(root, a.Path)
|
return doList(root, a.Path)
|
||||||
|
case "run_command":
|
||||||
|
return doRunCommand(root, a.Command, a.TimeoutSeconds)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("error: unknown tool %q", name)
|
return fmt.Sprintf("error: unknown tool %q", name)
|
||||||
}
|
}
|
||||||
@@ -197,6 +250,63 @@ func doDelete(root, p string) string {
|
|||||||
return fmt.Sprintf("deleted %s", p)
|
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 {
|
func doList(root, p string) string {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
p = "."
|
p = "."
|
||||||
|
|||||||
Reference in New Issue
Block a user