feat: add git_push and create_pr tools for worktree-safe git operations

Adds two dedicated tools that bypass the shell exec deny patterns while
maintaining strict safety invariants:

- git_push: pushes the current worktree branch to origin. Requires active
  worktree context, blocks protected branches (main/master/develop/release/*),
  forbids force push, auto-commits uncommitted changes before pushing.

- create_pr: creates a GitHub PR via `gh` CLI. Auto-detects base branch from
  worktree info, verifies branch is pushed, checks for merge conflicts with
  base via `git merge-tree --write-tree` before creating.

Sandbox integration:
- coder+ presets: git_push allowed
- worker+ presets: create_pr also allowed
- WorktreeInfo injected into tool context alongside workspace override

https://claude.ai/code/session_01WWttNE5xShanYD6PhMzgKz
This commit is contained in:
Claude 2026-03-01 06:20:59 +00:00
parent 3706b2d5f0
commit 2224a062fa
8 changed files with 694 additions and 1 deletions

View file

@ -80,6 +80,8 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewLogsTool())
toolsRegistry.Register(tools.NewGitPushTool(workspace))
toolsRegistry.Register(tools.NewCreatePRTool(workspace))
sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir)

View file

@ -2637,6 +2637,7 @@ func (al *AgentLoop) runLLMIteration(
toolCtx := ctx
if wt := agent.GetWorktree(opts.SessionKey); wt != nil {
toolCtx = tools.WithWorkspaceOverride(toolCtx, wt.Path)
toolCtx = tools.WithWorktreeInfo(toolCtx, wt)
}
toolResult := agent.Tools.ExecuteWithContext(
toolCtx,

175
pkg/tools/createpr.go Normal file
View file

@ -0,0 +1,175 @@
package tools
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
// CreatePRTool creates a GitHub pull request from the current worktree branch.
//
// Safety invariants:
// - Only works inside a worktree (WorktreeInfo must be in context)
// - Base branch is auto-detected from WorktreeInfo.BaseBranch
// - Requires the branch to be already pushed (use git_push first)
// - Checks for merge conflicts with base before creating
// - Uses `gh pr create` under the hood
type CreatePRTool struct {
workspace string
}
// NewCreatePRTool creates a CreatePRTool.
func NewCreatePRTool(workspace string) *CreatePRTool {
return &CreatePRTool{workspace: workspace}
}
func (t *CreatePRTool) Name() string { return "create_pr" }
func (t *CreatePRTool) Description() string {
return "Create a GitHub pull request from the current worktree branch. " +
"The base branch is auto-detected from the worktree's parent branch. " +
"The branch must be pushed to origin first (use git_push). " +
"Checks for merge conflicts with the base branch before creating. " +
"Requires the `gh` CLI to be installed and authenticated."
}
func (t *CreatePRTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{
"type": "string",
"description": "Pull request title",
},
"body": map[string]any{
"type": "string",
"description": "Pull request body/description (supports markdown)",
},
"draft": map[string]any{
"type": "boolean",
"description": "Create as draft PR (default: false)",
},
},
"required": []string{"title"},
}
}
func (t *CreatePRTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
wt := WorktreeInfoFromCtx(ctx)
if wt == nil {
return ErrorResult(
"create_pr requires an active worktree.\n" +
"This tool can only be used during worktree-based sessions " +
"(e.g., heartbeat tasks or plan executing phase).\n" +
"The worktree provides the branch name and base branch for the PR.")
}
branch := wt.Branch
if branch == "" {
return ErrorResult(
"worktree has no branch name.\n" +
"The WorktreeInfo was set but Branch is empty. " +
"This is an internal error — the worktree may not have been created correctly.")
}
baseBranch := wt.BaseBranch
if baseBranch == "" {
baseBranch = "main"
}
title, ok := args["title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return ErrorResult(
"title is required.\n" +
"Provide a concise PR title describing the change (e.g., \"Add rate limiter to API endpoints\").")
}
// Verify the branch has been pushed by checking if the remote ref exists
checkCtx, checkCancel := context.WithTimeout(ctx, 15*time.Second)
defer checkCancel()
checkCmd := exec.CommandContext(checkCtx, "git", "ls-remote", "--exit-code", "origin", branch)
checkCmd.Dir = wt.Path
if err := checkCmd.Run(); err != nil {
return ErrorResult(fmt.Sprintf(
"branch %q not found on origin.\n"+
"The branch must be pushed before creating a PR. Use the git_push tool first.\n"+
"git_push will auto-commit uncommitted changes and push the worktree branch to origin.",
branch))
}
// Fetch latest base branch and check for merge conflicts
fetchCtx, fetchCancel := context.WithTimeout(ctx, 30*time.Second)
defer fetchCancel()
fetchCmd := exec.CommandContext(fetchCtx, "git", "fetch", "origin", baseBranch)
fetchCmd.Dir = wt.Path
if out, err := fetchCmd.CombinedOutput(); err != nil {
return ErrorResult(fmt.Sprintf(
"failed to fetch origin/%s: %s\n%s\n"+
"Cannot verify merge compatibility without the latest base branch. "+
"Check network connectivity and that the base branch %q exists on origin.",
baseBranch, err, strings.TrimSpace(string(out)), baseBranch))
}
// Try a merge dry-run to detect conflicts.
// merge-tree --write-tree is a plumbing command (Git 2.38+) that performs a
// three-way merge entirely in-memory without touching the working tree.
// Exit code 0 = clean merge, non-zero = conflicts detected.
mergeCtx, mergeCancel := context.WithTimeout(ctx, 30*time.Second)
defer mergeCancel()
mergeCmd := exec.CommandContext(mergeCtx, "git", "merge-tree",
"--write-tree", "--no-messages",
branch, "origin/"+baseBranch)
mergeCmd.Dir = wt.RepoRoot
mergeOut, mergeErr := mergeCmd.CombinedOutput()
if mergeErr != nil {
conflictInfo := strings.TrimSpace(string(mergeOut))
return ErrorResult(fmt.Sprintf(
"merge conflict detected between %q and %s.\n"+
"The PR cannot be created cleanly. Resolve the conflicts in the worktree first, "+
"then use git_push to push the resolution before retrying create_pr.\n"+
"Conflict details:\n%s",
branch, baseBranch, conflictInfo))
}
// Build gh pr create command
ghArgs := []string{"pr", "create",
"--base", baseBranch,
"--head", branch,
"--title", title,
}
if body, ok := args["body"].(string); ok && body != "" {
ghArgs = append(ghArgs, "--body", body)
} else {
ghArgs = append(ghArgs, "--body", "")
}
if draft, ok := args["draft"].(bool); ok && draft {
ghArgs = append(ghArgs, "--draft")
}
prCtx, prCancel := context.WithTimeout(ctx, 30*time.Second)
defer prCancel()
cmd := exec.CommandContext(prCtx, "gh", ghArgs...)
cmd.Dir = wt.RepoRoot
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
return ErrorResult(fmt.Sprintf(
"gh pr create failed: %s\n%s\n"+
"Possible causes:\n"+
"- gh CLI not installed or not authenticated (run `gh auth login`)\n"+
"- A PR already exists for branch %q (check with `gh pr list`)\n"+
"- Repository not configured as a GitHub remote",
err, output, branch))
}
return NewToolResult(fmt.Sprintf(
"Pull request created: %s\n"+
"Branch: %s -> %s",
output, branch, baseBranch))
}

173
pkg/tools/createpr_test.go Normal file
View file

@ -0,0 +1,173 @@
package tools
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/git"
)
// TestCreatePRTool_NoWorktree verifies that create_pr fails without worktree context.
func TestCreatePRTool_NoWorktree(t *testing.T) {
tool := NewCreatePRTool(t.TempDir())
result := tool.Execute(context.Background(), map[string]any{
"title": "Test PR",
})
if !result.IsError {
t.Fatal("expected error when no worktree in context")
}
assertContains(t, result.ForLLM, "worktree")
assertContains(t, result.ForLLM, "heartbeat")
}
// TestCreatePRTool_EmptyBranch verifies that empty branch name is rejected.
func TestCreatePRTool_EmptyBranch(t *testing.T) {
tool := NewCreatePRTool(t.TempDir())
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
Branch: "",
BaseBranch: "main",
Path: t.TempDir(),
RepoRoot: t.TempDir(),
})
result := tool.Execute(ctx, map[string]any{
"title": "Test PR",
})
if !result.IsError {
t.Fatal("expected error for empty branch")
}
assertContains(t, result.ForLLM, "no branch name")
}
// TestCreatePRTool_MissingTitle verifies that missing title is rejected.
func TestCreatePRTool_MissingTitle(t *testing.T) {
tool := NewCreatePRTool(t.TempDir())
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
Branch: "plan/test",
BaseBranch: "main",
Path: t.TempDir(),
RepoRoot: t.TempDir(),
})
tests := []struct {
name string
args map[string]any
}{
{"no title key", map[string]any{}},
{"empty title", map[string]any{"title": ""}},
{"whitespace title", map[string]any{"title": " "}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tool.Execute(ctx, tt.args)
if !result.IsError {
t.Fatal("expected error for missing/empty title")
}
assertContains(t, result.ForLLM, "title is required")
})
}
}
// TestCreatePRTool_BranchNotPushed verifies the tool checks for remote branch existence.
func TestCreatePRTool_BranchNotPushed(t *testing.T) {
tool := NewCreatePRTool(t.TempDir())
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
Branch: "plan/not-pushed",
BaseBranch: "main",
Path: t.TempDir(),
RepoRoot: t.TempDir(),
})
result := tool.Execute(ctx, map[string]any{
"title": "Test PR",
})
if !result.IsError {
t.Fatal("expected error for unpushed branch")
}
// Should mention git_push as the remedy
assertContains(t, result.ForLLM, "git_push")
}
// TestCreatePRTool_DefaultBaseBranch verifies fallback to "main" when BaseBranch is empty.
func TestCreatePRTool_DefaultBaseBranch(t *testing.T) {
tool := NewCreatePRTool(t.TempDir())
// With empty BaseBranch, tool should default to "main"
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
Branch: "plan/test",
BaseBranch: "",
Path: t.TempDir(),
RepoRoot: t.TempDir(),
})
result := tool.Execute(ctx, map[string]any{
"title": "Test PR",
})
// Will fail at ls-remote (no real repo), but should not fail at baseBranch validation
if result.IsError && contains(result.ForLLM, "base branch") {
t.Fatal("should not fail on base branch when defaulting to main")
}
}
// TestCreatePRTool_Interface verifies the tool satisfies the Tool interface.
func TestCreatePRTool_Interface(t *testing.T) {
var _ Tool = (*CreatePRTool)(nil)
tool := NewCreatePRTool(t.TempDir())
if tool.Name() != "create_pr" {
t.Errorf("Name: got %q, want %q", tool.Name(), "create_pr")
}
if tool.Description() == "" {
t.Error("Description should not be empty")
}
params := tool.Parameters()
if params == nil {
t.Fatal("Parameters should not be nil")
}
// Verify "title" is required
required, ok := params["required"].([]string)
if !ok {
t.Fatal("required should be []string")
}
foundTitle := false
for _, r := range required {
if r == "title" {
foundTitle = true
}
}
if !foundTitle {
t.Error("title should be in required parameters")
}
}
// TestAllowedToolsForPreset_GitTools checks git tools are correctly assigned to presets.
func TestAllowedToolsForPreset_GitTools(t *testing.T) {
tests := []struct {
name string
preset Preset
wantGitPush bool
wantCreatePR bool
}{
{"scout", PresetScout, false, false},
{"analyst", PresetAnalyst, false, false},
{"coder", PresetCoder, true, false},
{"worker", PresetWorker, true, true},
{"coordinator", PresetCoordinator, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed := AllowedToolsForPreset(tt.preset)
if got := allowed["git_push"]; got != tt.wantGitPush {
t.Errorf("git_push: got %v, want %v", got, tt.wantGitPush)
}
if got := allowed["create_pr"]; got != tt.wantCreatePR {
t.Errorf("create_pr: got %v, want %v", got, tt.wantCreatePR)
}
})
}
}

144
pkg/tools/gitpush.go Normal file
View file

@ -0,0 +1,144 @@
package tools
import (
"context"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/git"
)
// worktreeInfoKey is the context key for passing WorktreeInfo to tools.
type worktreeInfoKey struct{}
// WithWorktreeInfo returns a context carrying the active WorktreeInfo.
func WithWorktreeInfo(ctx context.Context, wt *git.WorktreeInfo) context.Context {
return context.WithValue(ctx, worktreeInfoKey{}, wt)
}
// WorktreeInfoFromCtx extracts the WorktreeInfo from context, or nil.
func WorktreeInfoFromCtx(ctx context.Context) *git.WorktreeInfo {
if v, ok := ctx.Value(worktreeInfoKey{}).(*git.WorktreeInfo); ok {
return v
}
return nil
}
// protectedBranches are branch names that can never be pushed to.
var protectedBranches = regexp.MustCompile(`^(main|master|develop|release/.*)$`)
// GitPushTool implements safe git push restricted to worktree branches.
//
// Safety invariants:
// - Only works inside a worktree (WorktreeInfo must be in context)
// - Pushes only the worktree's branch — no arbitrary branch targets
// - Protected branches (main, master, develop, release/*) are blocked
// - Force push is never allowed
// - Auto-commits uncommitted changes before pushing
type GitPushTool struct {
workspace string
}
// NewGitPushTool creates a GitPushTool.
func NewGitPushTool(workspace string) *GitPushTool {
return &GitPushTool{workspace: workspace}
}
func (t *GitPushTool) Name() string { return "git_push" }
func (t *GitPushTool) Description() string {
return "Push the current worktree branch to origin. Only works inside a git worktree. " +
"Auto-commits uncommitted changes before pushing. " +
"Protected branches (main, master, develop) cannot be pushed to. Force push is not allowed."
}
func (t *GitPushTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"commit_message": map[string]any{
"type": "string",
"description": "Commit message for uncommitted changes. If omitted, uncommitted changes are auto-committed with a default message.",
},
},
"required": []string{},
}
}
func (t *GitPushTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
wt := WorktreeInfoFromCtx(ctx)
if wt == nil {
return ErrorResult(
"git_push requires an active worktree.\n" +
"This tool can only be used during worktree-based sessions " +
"(e.g., heartbeat tasks or plan executing phase).\n" +
"The worktree provides the branch name and isolation boundary — " +
"without it, git_push cannot determine which branch to push.")
}
branch := wt.Branch
if branch == "" {
return ErrorResult(
"worktree has no branch name.\n" +
"The WorktreeInfo was set but Branch is empty. " +
"This is an internal error — the worktree may not have been created correctly.")
}
// Block protected branches
if protectedBranches.MatchString(branch) {
return ErrorResult(fmt.Sprintf(
"cannot push to protected branch %q.\n"+
"Protected branches (main, master, develop, release/*) are blocked to prevent "+
"accidental overwrites. Work should be done on feature branches created by worktrees.",
branch))
}
// Auto-commit uncommitted changes
if git.HasUncommittedChanges(wt.Path) {
commitMsg := "auto: save before push"
if msg, ok := args["commit_message"].(string); ok && msg != "" {
commitMsg = msg
}
if err := git.AutoCommit(wt.Path, commitMsg); err != nil {
return ErrorResult(fmt.Sprintf(
"auto-commit failed before push: %v\n"+
"git_push auto-commits uncommitted changes before pushing. "+
"The commit failed, so no push was attempted. "+
"Check if the worktree at %q is in a valid state (e.g., no merge conflicts).",
err, wt.Path))
}
}
// Check there are commits to push
ahead := git.CommitsAhead(wt.RepoRoot, wt.BaseBranch, branch)
if ahead == 0 {
return NewToolResult(fmt.Sprintf(
"Nothing to push: branch %q has no commits ahead of %s.\n"+
"The branch is identical to the base. Make changes and commit before pushing.",
branch, wt.BaseBranch))
}
// Push with -u (set upstream tracking)
pushCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(pushCtx, "git", "push", "-u", "origin", branch)
cmd.Dir = wt.Path
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
return ErrorResult(fmt.Sprintf(
"git push failed for branch %q: %s\n%s\n"+
"Possible causes: network error, authentication failure, or remote rejected the push. "+
"If the remote branch has diverged, resolve the divergence in the worktree first — "+
"force push is not available.",
branch, err, output))
}
return NewToolResult(fmt.Sprintf("Pushed branch %q to origin (%d commit(s) ahead of %s)\n%s",
branch, ahead, wt.BaseBranch, output))
}

184
pkg/tools/gitpush_test.go Normal file
View file

@ -0,0 +1,184 @@
package tools
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/git"
)
// TestGitPushTool_NoWorktree verifies that git_push fails without worktree context.
func TestGitPushTool_NoWorktree(t *testing.T) {
tool := NewGitPushTool(t.TempDir())
result := tool.Execute(context.Background(), map[string]any{})
if !result.IsError {
t.Fatal("expected error when no worktree in context")
}
if result.ForLLM == "" {
t.Fatal("error message should not be empty")
}
// Verify helpful guidance is included
assertContains(t, result.ForLLM, "worktree")
assertContains(t, result.ForLLM, "heartbeat")
}
// TestGitPushTool_ProtectedBranch verifies that protected branches are blocked.
func TestGitPushTool_ProtectedBranch(t *testing.T) {
tool := NewGitPushTool(t.TempDir())
protectedNames := []string{"main", "master", "develop", "release/v1.0"}
for _, branch := range protectedNames {
t.Run(branch, func(t *testing.T) {
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
Branch: branch,
BaseBranch: "main",
Path: t.TempDir(),
RepoRoot: t.TempDir(),
})
result := tool.Execute(ctx, map[string]any{})
if !result.IsError {
t.Fatalf("expected error for protected branch %q", branch)
}
assertContains(t, result.ForLLM, "protected")
assertContains(t, result.ForLLM, branch)
})
}
}
// TestGitPushTool_EmptyBranch verifies that empty branch name is rejected.
func TestGitPushTool_EmptyBranch(t *testing.T) {
tool := NewGitPushTool(t.TempDir())
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
Branch: "",
BaseBranch: "main",
Path: t.TempDir(),
RepoRoot: t.TempDir(),
})
result := tool.Execute(ctx, map[string]any{})
if !result.IsError {
t.Fatal("expected error for empty branch")
}
assertContains(t, result.ForLLM, "no branch name")
}
// TestGitPushTool_AllowedBranch verifies that non-protected branches pass the branch check.
// (Push itself will fail because there's no real git repo, but it should get past validation.)
func TestGitPushTool_AllowedBranch(t *testing.T) {
tool := NewGitPushTool(t.TempDir())
allowedNames := []string{"plan/add-auth", "feature/foo", "worktree/test"}
for _, branch := range allowedNames {
t.Run(branch, func(t *testing.T) {
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
Branch: branch,
BaseBranch: "main",
Path: t.TempDir(),
RepoRoot: t.TempDir(),
})
result := tool.Execute(ctx, map[string]any{})
// Should NOT fail with "protected branch" error
if result.IsError && contains(result.ForLLM, "protected") {
t.Fatalf("branch %q should not be blocked as protected", branch)
}
})
}
}
// TestProtectedBranchesRegex tests the regex directly.
func TestProtectedBranchesRegex(t *testing.T) {
tests := []struct {
branch string
protected bool
}{
{"main", true},
{"master", true},
{"develop", true},
{"release/v1.0", true},
{"release/2026-03", true},
{"plan/add-feature", false},
{"feature/main", false}, // "main" not at start
{"main-backup", false}, // "main" followed by suffix
{"hotfix/urgent", false},
}
for _, tt := range tests {
t.Run(tt.branch, func(t *testing.T) {
got := protectedBranches.MatchString(tt.branch)
if got != tt.protected {
t.Errorf("branch %q: got protected=%v, want %v", tt.branch, got, tt.protected)
}
})
}
}
// TestWorktreeInfoContext verifies context round-trip.
func TestWorktreeInfoContext(t *testing.T) {
wt := &git.WorktreeInfo{
Branch: "plan/test",
BaseBranch: "main",
Path: "/tmp/wt",
RepoRoot: "/tmp/repo",
}
ctx := WithWorktreeInfo(context.Background(), wt)
got := WorktreeInfoFromCtx(ctx)
if got == nil {
t.Fatal("expected non-nil WorktreeInfo from context")
}
if got.Branch != wt.Branch {
t.Errorf("Branch: got %q, want %q", got.Branch, wt.Branch)
}
if got.BaseBranch != wt.BaseBranch {
t.Errorf("BaseBranch: got %q, want %q", got.BaseBranch, wt.BaseBranch)
}
// Nil case
got2 := WorktreeInfoFromCtx(context.Background())
if got2 != nil {
t.Errorf("expected nil WorktreeInfo from bare context, got %+v", got2)
}
}
// TestGitPushTool_Interface verifies the tool satisfies the Tool interface.
func TestGitPushTool_Interface(t *testing.T) {
var _ Tool = (*GitPushTool)(nil)
tool := NewGitPushTool(t.TempDir())
if tool.Name() != "git_push" {
t.Errorf("Name: got %q, want %q", tool.Name(), "git_push")
}
if tool.Description() == "" {
t.Error("Description should not be empty")
}
params := tool.Parameters()
if params == nil {
t.Fatal("Parameters should not be nil")
}
if params["type"] != "object" {
t.Errorf("Parameters type: got %v, want object", params["type"])
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
}
func containsStr(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func assertContains(t *testing.T, s, substr string) {
t.Helper()
if !contains(s, substr) {
t.Errorf("expected %q to contain %q", s, substr)
}
}

View file

@ -108,12 +108,18 @@ func AllowedToolsForPreset(p Preset) map[string]bool {
allowed["exec"] = true
}
// Add coder/worker/coordinator tools (write, bg_monitor)
// Add coder/worker/coordinator tools (write, bg_monitor, git_push)
if p == PresetCoder || p == PresetWorker || p == PresetCoordinator {
allowed["write_file"] = true
allowed["edit_file"] = true
allowed["append_file"] = true
allowed["bg_monitor"] = true
allowed["git_push"] = true
}
// Add worker/coordinator tools (create_pr)
if p == PresetWorker || p == PresetCoordinator {
allowed["create_pr"] = true
}
// Add coordinator-only tools (spawn)

View file

@ -358,6 +358,14 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string)
}
}
// Register git tools (worktree-safe push and PR creation)
if config.AllowedTools["git_push"] {
registry.Register(NewGitPushTool(sm.workspace))
}
if config.AllowedTools["create_pr"] {
registry.Register(NewCreatePRTool(sm.workspace))
}
// Register web tools
if config.AllowedTools["web_search"] {
webSearchTool := NewWebSearchTool(sm.webSearchOpts)