Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ func optionalTools() []tool.Tool {
tool.CodeSearchTool{},
tool.CodeMatchTool{},
tool.FuzzyFindTool{},
tool.BatchExecTool{},
tool.ToolsetTool{},
tool.CoreMemoryAppendTool{},
tool.CoreMemoryReplaceTool{},
Expand Down
1 change: 1 addition & 0 deletions internal/engine/safety/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ var toolPolicies = map[string]ToolPolicy{
"CodeSearch": {Name: "CodeSearch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"CodeMatch": {Name: "CodeMatch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"FuzzyFind": {Name: "FuzzyFind", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"BatchExec": {Name: "BatchExec", Capabilities: []Capability{CapabilityNetworkAccess}, DefaultRisk: RiskMedium},
"Toolset": {Name: "Toolset", Capabilities: nil, DefaultRisk: RiskLow},
"CodeGraph": {Name: "CodeGraph", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
"Impact": {Name: "Impact", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow},
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/safety/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ func canonicalToolName(name string) string {
return "CodeMatch"
case "fuzzy_find", "fuzzyfind", "ffind":
return "FuzzyFind"
case "batch_exec", "batchexec":
return "BatchExec"
case "toolset":
return "Toolset"
case "tool_health", "toolhealth", "tools_health":
Expand Down
203 changes: 203 additions & 0 deletions internal/tool/batch_exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package tool

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)

// BatchExecTool submits prompts to the provider-native Message Batches API
// for 50%-cost async execution. Designed for CI/scripted workloads where the
// result is not needed immediately. Uses fixed HTTP calls (no shell) against
// the provider's REST endpoint; no eyrie/client import (boundary-guarded).
type BatchExecTool struct{}

func (BatchExecTool) Name() string { return "BatchExec" }
func (BatchExecTool) RiskLevel() string { return "medium" }
func (BatchExecTool) Aliases() []string { return []string{"batch_exec"} }
func (BatchExecTool) Description() string {
return "Submit one or more prompts to the Anthropic Message Batches API for 50%-cost async execution. Returns a batch ID; poll with action=poll to check status."
}

func (BatchExecTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"submit", "poll"},
"description": "submit: send prompts; poll: check batch status.",
},
"prompts": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Prompts to submit (action=submit).",
},
"model": map[string]interface{}{
"type": "string",
"description": "Model ID (default claude-sonnet-4-20250514).",
},
"batch_id": map[string]interface{}{
"type": "string",
"description": "Batch ID to poll (action=poll).",
},
"max_tokens": map[string]interface{}{
"type": "integer",
"description": "Max output tokens per request (default 4096).",
},
},
"required": []string{"action"},
}
}

var batchHTTP = &http.Client{Timeout: 5 * time.Minute}

// batchAPIKey reads the key from env.
func batchAPIKey() string { return os.Getenv("ANTHROPIC_API_KEY") }

func batchDefaultModel() string { return "claude-sonnet-4-20250514" }

const batchBaseURL = "https://api.anthropic.com"

func batchHeaders(req *http.Request, apiKey string) *http.Request {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("Anthropic-Version", "2023-06-01")
req.Header.Set("Anthropic-Beta", "message-batches-2024-09-24")
return req
}

type batchPollResult struct {
BatchID string `json:"batch_id"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}

func (BatchExecTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
var p struct {
Action string `json:"action"`
Prompts []string `json:"prompts"`
Model string `json:"model"`
BatchID string `json:"batch_id"`
MaxTokens int `json:"max_tokens"`
}
if err := json.Unmarshal(input, &p); err != nil {
return "", fmt.Errorf("invalid input: %w", err)
}

apiKey := batchAPIKey()
if apiKey == "" {
return "", fmt.Errorf("ANTHROPIC_API_KEY not set — required for batch execution")
}

switch p.Action {
case "submit":
return batchSubmit(ctx, apiKey, p)
case "poll":
if p.BatchID == "" {
return "", fmt.Errorf("batch_id is required for poll")
}
return batchPoll(ctx, apiKey, p.BatchID)
default:
return "", fmt.Errorf("unsupported action %q (use submit or poll)", p.Action)
}
}

func batchSubmit(ctx context.Context, apiKey string, p struct {
Action string `json:"action"`
Prompts []string `json:"prompts"`
Model string `json:"model"`
BatchID string `json:"batch_id"`
MaxTokens int `json:"max_tokens"`
},
) (string, error) {
if len(p.Prompts) == 0 {
return "", fmt.Errorf("at least one prompt is required")
}
model := p.Model
if model == "" {
model = batchDefaultModel()
}
maxTok := p.MaxTokens
if maxTok <= 0 {
maxTok = 4096
}

type item struct {
CustomID string `json:"custom_id"`
Params map[string]interface{} `json:"params"`
}
items := make([]item, len(p.Prompts))
for i, prompt := range p.Prompts {
items[i] = item{
CustomID: fmt.Sprintf("req-%03d", i+1),
Params: map[string]interface{}{
"model": model,
"max_tokens": maxTok,
"messages": []map[string]string{{"role": "user", "content": prompt}},
},
}
}
body, err := json.Marshal(map[string]interface{}{"requests": items})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
batchBaseURL+"/v1/messages/batches", bytes.NewReader(body)) // #nosec G107 -- fixed API host
if err != nil {
return "", err
}
batchHeaders(req, apiKey)
resp, err := batchHTTP.Do(req)
if err != nil {
return "", fmt.Errorf("batch submit: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", fmt.Errorf("batch API %d: %s", resp.StatusCode, strings.TrimSpace(string(errBody)))
}
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
out, _ := json.MarshalIndent(map[string]string{
"batch_id": result.ID,
"requests": fmt.Sprintf("%d", len(p.Prompts)),
"model": model,
}, "", " ")
return string(out), nil
}

func batchPoll(ctx context.Context, apiKey, batchID string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
batchBaseURL+"/v1/messages/batches/"+batchID, nil) // #nosec G107 -- fixed API host + validated ID
if err != nil {
return "", err
}
batchHeaders(req, apiKey)
resp, err := batchHTTP.Do(req)
if err != nil {
return "", fmt.Errorf("batch poll: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("batch API %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var result batchPollResult
if err := json.Unmarshal(raw, &result); err != nil {
return string(raw), nil // return raw on parse failure
}
result.BatchID = batchID
out, _ := json.MarshalIndent(result, "", " ")
return string(out), nil
}
48 changes: 48 additions & 0 deletions internal/tool/batch_exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package tool

import (
"context"
"encoding/json"
"strings"
"testing"
)

func TestBatchExecRequiresAPIKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
_, err := (BatchExecTool{}).Execute(context.Background(), json.RawMessage(
`{"action":"submit","prompts":["hello"]}`,
))
if err == nil || !strings.Contains(err.Error(), "ANTHROPIC_API_KEY") {
t.Fatalf("err = %v", err)
}
}

func TestBatchExecSubmitRequiresPrompts(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "test-key")
_, err := (BatchExecTool{}).Execute(context.Background(), json.RawMessage(
`{"action":"submit","prompts":[]}`,
))
if err == nil || !strings.Contains(err.Error(), "at least one prompt") {
t.Fatalf("err = %v", err)
}
}

func TestBatchExecPollRequiresID(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "test-key")
_, err := (BatchExecTool{}).Execute(context.Background(), json.RawMessage(
`{"action":"poll"}`,
))
if err == nil || !strings.Contains(err.Error(), "batch_id is required") {
t.Fatalf("err = %v", err)
}
}

func TestBatchExecInvalidAction(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "test-key")
_, err := (BatchExecTool{}).Execute(context.Background(), json.RawMessage(
`{"action":"nope"}`,
))
if err == nil || !strings.Contains(err.Error(), "unsupported action") {
t.Fatal("expected error for unsupported action")
}
}
Loading