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 @@ -97,6 +97,7 @@ func optionalTools() []tool.Tool {
tool.OutlineTool{},
&tool.SmartReaderTool{},
tool.PatchTool{},
tool.BatchTool{},
tool.TransactionTool{},
tool.NewAutoImportTool(),
tool.ImportOrganizerTool{},
Expand Down
27 changes: 17 additions & 10 deletions docs/plans/codex-adoption-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,26 @@ Three codex ideas are deliberately deferred as future RFCs; see
`permission.sandbox_backend`, so operators can confirm real kernel-level
isolation (seatbelt on macOS, landlock/seccomp on Linux, ACL on Windows,
docker fallbacks) instead of only the strict/workspace/off label.
- **Batch tool** (safe core of codex Code Mode): a `Batch` tool runs a list of
read-only tool calls in a single turn, cutting agent round-trips for fan-out
research. It reuses the existing read-only allowlist and per-call schema
validation, so no mutation can bypass the normal tool pipeline. It delivers
Code Mode's primary token/round-trip benefit without embedding a script
runtime or adding a new execution authority boundary.

## Deliberately Deferred

- **Code Mode** (`code-mode`, `code-mode-runtime`, `v8-poc`): letting the model
author a short script that batches many tool calls into one sandboxed
execution. Promising token-cost lever, but it introduces an embedded JS
runtime and a new execution authority boundary. Requires its own threat
model (script capabilities, network/file scope, output trust) before any
implementation. Track as a standalone RFC.
- **Agent identity signing** (`agent-identity`): cryptographic identity for
agents and subagents woven into audit records. hawk's tamper-evident
security log covers integrity today; signed delegation chains are worth a
focused design once multi-org delegation exists.
- **Full Code Mode** (`code-mode`, `code-mode-runtime`, `v8-poc`): letting the
model author an arbitrary script that batches tool calls into one sandboxed
execution, including mutation and control flow. The `Batch` tool above covers
the safe read-only fan-out case. Arbitrary-script execution still requires an
embedded runtime, capabilities model, and output-trust threat model; track as
a standalone RFC.
- **Agent identity signing** (`agent-identity`): hawk already provides a
per-harness anonymous user identity (`internal/identity`) and a tamper-evident
HMAC-chained security log with session-scoped events (`internal/securitylog`).
Signed subagent delegation chains are worth a focused design once multi-org
delegation exists.
- **Cloud tasks client** (`cloud-tasks*`): remote task queue integration.
Hawk Cloud already provides sync/review surfaces; a queue protocol would
duplicate that until a concrete consumer exists.
Expand Down
94 changes: 94 additions & 0 deletions internal/tool/batch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package tool

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)

// BatchTool executes a list of read-only tool calls in a single turn, reducing
// the round-trips the agent needs for fan-out research work (the safe core of
// codex's "code mode" batching, without an embedded script runtime).
//
// Security: only read-only tools (see IsReadOnly) are allowed. Every inner
// call is resolved through the session registry, schema-validated, and executed
// individually — no mutation can bypass the normal tool pipeline because a
// non-read-only call fails the request up front.
type BatchTool struct{}

func (BatchTool) Name() string { return "Batch" }

func (BatchTool) Aliases() []string { return []string{"batch"} }

func (BatchTool) Description() string {
return "Run several read-only tool calls in one turn (fan-out research). Calls must be read-only tools."
}

func (BatchTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"calls": map[string]interface{}{
"type": "array",
"description": "Read-only tool calls to execute in sequence",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"tool": map[string]interface{}{"type": "string", "description": "Read-only tool name (e.g. Read, Grep, Glob, LS, CodeSearch)"},
"input": map[string]interface{}{"type": "object", "description": "Tool input object"},
},
"required": []string{"tool"},
},
},
},
"required": []string{"calls"},
}
}

type batchCall struct {
Tool string `json:"tool"`
Input json.RawMessage `json:"input"`
}

func (BatchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
var p struct {
Calls []batchCall `json:"calls"`
}
if err := json.Unmarshal(input, &p); err != nil {
return "", fmt.Errorf("batch: %w", err)
}
if len(p.Calls) == 0 {
return "", errors.New("batch: calls is required and must not be empty")
}

tc := GetToolContext(ctx)
if tc == nil || tc.Registry == nil {
return "", errors.New("batch: tool registry unavailable in this context")
}

var b strings.Builder
for i, call := range p.Calls {
name := strings.TrimSpace(call.Tool)
if name == "" {
return "", fmt.Errorf("batch: call %d: tool name is required", i)
}
if !IsReadOnly(name) {
return "", fmt.Errorf("batch: call %d: %q is not read-only; batch only executes read-only tools", i, name)
}
inner, ok := tc.Registry.Get(name)
if !ok {
return "", fmt.Errorf("batch: call %d: unknown tool %q", i, name)
}
if err := ValidateToolInput(inner, call.Input); err != nil {
return "", fmt.Errorf("batch: call %d (%s): %w", i, name, err)
}
out, err := inner.Execute(ctx, call.Input)
if err != nil {
return "", fmt.Errorf("batch: call %d (%s): %w", i, name, err)
}
fmt.Fprintf(&b, "## %s\n%s\n\n", name, strings.TrimSpace(out))
}
return strings.TrimRight(b.String(), "\n"), nil
}
82 changes: 82 additions & 0 deletions internal/tool/batch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package tool

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

// stubTool is a trivial read-only tool used to exercise the batch path without
// depending on the full registry of real tools.
type stubTool struct{}

func (stubTool) Name() string { return "StubRead" }
func (stubTool) Description() string { return "test read-only tool" }
func (stubTool) Parameters() map[string]interface{} {
return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
}
func (stubTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { return "ok", nil }

// batchRegistry builds a registry containing a canonical read-only name that
// IsReadOnly recognizes, backed by the test stub.
func batchRegistry() *Registry {
r := NewRegistry()
_ = r.Register(stubTool{})
// Alias the stub under the canonical read-only "Read" name so the guard
// passes in tests without importing the whole tool set.
_ = r.Register(readAlias{inner: stubTool{}})
return r
}

// readAlias presents stubTool under the read-only "Read" name.
type readAlias struct{ inner Tool }

func (a readAlias) Name() string { return "Read" }
func (a readAlias) Aliases() []string { return []string{"read"} }
func (a readAlias) Description() string { return a.inner.Description() }
func (a readAlias) Parameters() map[string]interface{} {
return a.inner.Parameters()
}

func (a readAlias) Execute(ctx context.Context, in json.RawMessage) (string, error) {
return a.inner.Execute(ctx, in)
}

func TestBatchExecutesReadOnlyCalls(t *testing.T) {
reg := batchRegistry()
ctx := WithToolContext(context.Background(), &ToolContext{Registry: reg})

var b BatchTool
out, err := b.Execute(ctx, []byte(`{"calls":[{"tool":"Read","input":{}},{"tool":"Read","input":{}}]}`))
if err != nil {
t.Fatalf("batch: %v", err)
}
if !strings.Contains(out, "## Read") || !strings.Contains(out, "ok") {
t.Fatalf("unexpected batch output: %q", out)
}
}

func TestBatchRejectsNonReadOnly(t *testing.T) {
reg := batchRegistry()
ctx := WithToolContext(context.Background(), &ToolContext{Registry: reg})

var b BatchTool
_, err := b.Execute(ctx, []byte(`{"calls":[{"tool":"Write","input":{}}]}`))
if err == nil {
t.Fatal("batch must reject a non-read-only tool")
}
}

func TestBatchRejectsEmptyAndUnknown(t *testing.T) {
reg := batchRegistry()
ctx := WithToolContext(context.Background(), &ToolContext{Registry: reg})
var b BatchTool

if _, err := b.Execute(ctx, []byte(`{"calls":[]}`)); err == nil {
t.Fatal("empty calls must error")
}
if _, err := b.Execute(ctx, []byte(`{"calls":[{"tool":"NoSuchTool","input":{}}]}`)); err == nil {
t.Fatal("unknown tool must error")
}
}
Loading