Skip to content
Draft
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ bazel-src-cli
.DS_Store
samples
.amp
bin/
1 change: 1 addition & 0 deletions cmd/src/batch_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ func convertWorkspace(w batcheslib.WorkspacesExecutionInput) *executor.Task {
BatchChangeAttributes: &w.BatchChangeAttributes,
CachedStepResultFound: w.CachedStepResultFound,
CachedStepResult: w.CachedStepResult,
ModelProviderURL: w.ModelProviderURL,
}

return task
Expand Down
69 changes: 67 additions & 2 deletions internal/batches/executor/run_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"time"

batcheslib "github.com/sourcegraph/sourcegraph/lib/batches"
"github.com/sourcegraph/sourcegraph/lib/batches/codingagent"
codingagenttypes "github.com/sourcegraph/sourcegraph/lib/batches/codingagent/types"
"github.com/sourcegraph/sourcegraph/lib/batches/execution"
"github.com/sourcegraph/sourcegraph/lib/batches/git"
"github.com/sourcegraph/sourcegraph/lib/batches/template"
Expand Down Expand Up @@ -272,12 +274,32 @@ func executeSingleStep(
return bytes.Buffer{}, bytes.Buffer{}, err
}

runScriptFile, runScript, cleanup, err := createRunScriptFile(ctx, opts.TempDir, step.Run, stepContext)
var (
runScriptFile string
runScript string
runScriptCleanup func()
)
if step.CodingAgent != nil {
if opts.Task.ModelProviderURL == "" {
err = errors.New("codingAgent step requires WorkspacesExecutionInput.ModelProviderURL to be set")
opts.UI.StepPreparingFailed(stepIdx+1, err)
return bytes.Buffer{}, bytes.Buffer{}, err
}
runScript, err = codingagent.RenderRunCommand(step.CodingAgent, opts.Task.ModelProviderURL, stepContext)
if err != nil {
err = errors.Wrap(err, "rendering codingAgent step")
opts.UI.StepPreparingFailed(stepIdx+1, err)
return bytes.Buffer{}, bytes.Buffer{}, err
}
runScriptFile, runScriptCleanup, err = writeRunScriptFile(opts.TempDir, runScript)
} else {
runScriptFile, runScript, runScriptCleanup, err = createRunScriptFile(ctx, opts.TempDir, step.Run, stepContext)
}
if err != nil {
opts.UI.StepPreparingFailed(stepIdx+1, err)
return bytes.Buffer{}, bytes.Buffer{}, err
}
defer cleanup()
defer runScriptCleanup()

// Parse and render the step.Files.
filesToMount, cleanup, err := createFilesToMount(opts.TempDir, step, stepContext)
Expand All @@ -303,6 +325,21 @@ func executeSingleStep(
return bytes.Buffer{}, bytes.Buffer{}, err
}

// For codingAgent steps, forward the model-provider auth env from the
// executor's environment (injected via CliStep.Env and JobTokenEnvVar on
// the server) into the user container so the agent CLI can talk to the
// /model-provider/batches proxy.
if step.CodingAgent != nil {
for _, key := range []string{codingagenttypes.ModelProviderTokenEnvVar, codingagenttypes.JobIDEnvVar} {
for _, e := range opts.GlobalEnv {
if v, ok := strings.CutPrefix(e, key+"="); ok {
env[key] = v
break
}
}
}
}

opts.UI.StepPreparingSuccess(stepIdx + 1)

// ----------
Expand Down Expand Up @@ -573,6 +610,34 @@ func createRunScriptFile(ctx context.Context, tempDir string, stepRun string, st
return runScriptFile.Name(), runScript.String(), cleanup, nil
}

// writeRunScriptFile writes a pre-rendered run script (e.g. a codingAgent
// step desugared by the codingagent registry) verbatim to a temp file, with
// the same permission semantics as createRunScriptFile. Unlike that helper,
// this one does NOT pass the content through template.RenderStepTemplate:
// the script is treated as opaque shell text so embedded sequences like
// `{{` inside a shell-quoted prompt are not re-parsed as templates.
func writeRunScriptFile(tempDir, script string) (string, func(), error) {
runScriptFile, err := os.CreateTemp(tempDir, "")
if err != nil {
return "", nil, errors.Wrap(err, "creating temporary file")
}
cleanup := func() { os.Remove(runScriptFile.Name()) }

if _, err := runScriptFile.WriteString(script); err != nil {
cleanup()
return "", nil, errors.Wrap(err, "writing temporary file")
}
if err := runScriptFile.Close(); err != nil {
cleanup()
return "", nil, errors.Wrap(err, "closing temporary file")
}
if err := os.Chmod(runScriptFile.Name(), 0644); err != nil {
cleanup()
return "", nil, errors.Wrap(err, "setting permissions on the temporary file")
}
return runScriptFile.Name(), cleanup, nil
}

// createCidFile creates a temporary file that will contain the container ID
// when executing steps.
// It returns the location of the file and a function that cleans up the
Expand Down
3 changes: 3 additions & 0 deletions internal/batches/executor/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ type Task struct {
// When this field is true, CachedStepResult is also populated.
CachedStepResultFound bool
CachedStepResult execution.AfterStepResult
// ModelProviderURL is the resolved proxy base URL for coding-agent
// steps; empty unless the spec contains at least one codingAgent step.
ModelProviderURL string
}

func (t *Task) ArchivePathToFetch() string {
Expand Down
40 changes: 33 additions & 7 deletions lib/batches/batch_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,29 @@ func (oqor *OnQueryOrRepository) GetBranches() ([]string, error) {
}

type Step struct {
Run string `json:"run,omitempty" yaml:"run"`
Container string `json:"container,omitempty" yaml:"container"`
Env env.Environment `json:"env" yaml:"env"`
Files map[string]string `json:"files,omitempty" yaml:"files,omitempty"`
Outputs Outputs `json:"outputs,omitempty" yaml:"outputs,omitempty"`
Mount []Mount `json:"mount,omitempty" yaml:"mount,omitempty"`
If any `json:"if,omitempty" yaml:"if,omitempty"`
Run string `json:"run,omitempty" yaml:"run"`
CodingAgent *CodingAgentStep `json:"codingAgent,omitempty" yaml:"codingAgent,omitempty"`
Container string `json:"container,omitempty" yaml:"container"`
Image string `json:"image,omitempty" yaml:"image,omitempty"`
Env env.Environment `json:"env" yaml:"env"`
Files map[string]string `json:"files,omitempty" yaml:"files,omitempty"`
Outputs Outputs `json:"outputs,omitempty" yaml:"outputs,omitempty"`
Mount []Mount `json:"mount,omitempty" yaml:"mount,omitempty"`
If any `json:"if,omitempty" yaml:"if,omitempty"`
}

// CodingAgentType identifies a registered coding-agent implementation.
type CodingAgentType string

const (
CodingAgentTypeCodex CodingAgentType = "codex"
)

// CodingAgentStep is a v3-spec step that delegates the step's work to a
// coding agent CLI invoked via the server-side model-provider proxy.
type CodingAgentStep struct {
Type CodingAgentType `json:"type,omitempty" yaml:"type"`
Prompt string `json:"prompt,omitempty" yaml:"prompt"`
}

func (s *Step) IfCondition() string {
Expand Down Expand Up @@ -161,6 +177,16 @@ func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) {
return nil, err
}

if spec.Version == 3 {
// Mirror v3 `image:` into `container:` so executor consumers that
// read step.Container keep working.
for i := range spec.Steps {
if spec.Steps[i].Image != "" {
spec.Steps[i].Container = spec.Steps[i].Image
}
}
}

var errs error

if len(spec.Steps) != 0 && spec.ChangesetTemplate == nil {
Expand Down
43 changes: 43 additions & 0 deletions lib/batches/codex/codex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Package codex implements the codex coding agent run-command rewrite.
package codex

import (
"fmt"

"github.com/kballard/go-shellquote"

batcheslib "github.com/sourcegraph/sourcegraph/lib/batches"
"github.com/sourcegraph/sourcegraph/lib/batches/codingagent/types"
)

const model = "gpt-5.4"

var routes = []types.ModelProviderRoute{
{WirePath: "/responses", UpstreamPath: "/v1/completions/openai-responses"},
}

type Agent struct{}

func (Agent) Type() batcheslib.CodingAgentType { return batcheslib.CodingAgentTypeCodex }
func (Agent) ModelProviderRoutes() []types.ModelProviderRoute { return routes }
func (Agent) ImageRequirements() []string { return []string{"codex"} }

func (Agent) RunCommand(prompt, modelProviderURL string) string {
return shellquote.Join(
"codex",
"exec",
"--json",
"--sandbox", "danger-full-access",
"--ephemeral",
"--model", model,
"-c", `approval_policy="never"`,
"-c", `model_reasoning_effort="medium"`,
"-c", `model_provider="sourcegraph"`,
"-c", `model_providers.sourcegraph.name="Sourcegraph"`,
"-c", fmt.Sprintf(`model_providers.sourcegraph.base_url=%q`, modelProviderURL),
"-c", fmt.Sprintf(`model_providers.sourcegraph.env_key=%q`, types.ModelProviderTokenEnvVar),
"-c", fmt.Sprintf(`model_providers.sourcegraph.env_http_headers={%q=%q}`, types.JobIDHeaderName, types.JobIDEnvVar),
"-c", `model_providers.sourcegraph.wire_api="responses"`,
prompt,
)
}
87 changes: 87 additions & 0 deletions lib/batches/codingagent/codingagent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Package codingagent rewrites v3 batch spec coding-agent steps into the
// shell commands that drive them. Register new agents in the agents list.
package codingagent

import (
"bytes"
"fmt"
"strings"

"github.com/kballard/go-shellquote"

batcheslib "github.com/sourcegraph/sourcegraph/lib/batches"
"github.com/sourcegraph/sourcegraph/lib/batches/codex"
"github.com/sourcegraph/sourcegraph/lib/batches/codingagent/types"
"github.com/sourcegraph/sourcegraph/lib/batches/template"
"github.com/sourcegraph/sourcegraph/lib/errors"
)

// ErrUnknownType is returned when a codingAgent step references a type that
// has no registered Agent.
var ErrUnknownType = errors.New("unknown codingAgent type")

// RenderRunCommand returns the shell-quoted command that runs agentStep. The
// prompt is rendered before quoting; reversing would let templated values
// break out of the shell quoting.
func RenderRunCommand(agentStep *batcheslib.CodingAgentStep, modelProviderURL string, stepCtx *template.StepContext) (string, error) {
a, ok := agents[agentStep.Type]
if !ok {
return "", errors.Wrapf(ErrUnknownType, "codingAgent type %q", agentStep.Type)
}
var renderedPrompt bytes.Buffer
if err := template.RenderStepTemplate("codingagent-prompt", agentStep.Prompt, &renderedPrompt, stepCtx); err != nil {
return "", errors.Wrap(err, "rendering codingAgent.prompt")
}
prefixed := strings.TrimRight(modelProviderURL, "/") + "/" + string(a.Type())

var b strings.Builder
for _, binary := range a.ImageRequirements() {
b.WriteString(failIfMissing(a.Type(), binary))
}
b.WriteString(a.RunCommand(renderedPrompt.String(), prefixed))
return b.String(), nil
}

// failIfMissing returns a shell snippet that, when prepended to the step
// script, writes a message to stderr and exits the container with status 1
// if binary isn't on PATH.
func failIfMissing(agentType batcheslib.CodingAgentType, binary string) string {
msg := fmt.Sprintf(
"codingAgent %q requires %q on PATH in the run container; ensure your image includes the %s binary",
agentType, binary, binary,
)
return fmt.Sprintf("command -v %s >/dev/null 2>&1 || { echo %s >&2; exit 1; }\n",
binary,
shellquote.Join(msg),
)
}

var agents = func() map[batcheslib.CodingAgentType]types.Agent {
out := map[batcheslib.CodingAgentType]types.Agent{}
for _, a := range []types.Agent{
codex.Agent{},
} {
if _, exists := out[a.Type()]; exists {
panic("duplicate codingagent agent for " + a.Type())
}
out[a.Type()] = a
}
return out
}()

// RegisteredModelProviderRoutes returns the model-provider proxy routes
// contributed by every registered Agent, each WirePath prefixed with
// its agent type.
func RegisteredModelProviderRoutes() []types.ModelProviderRoute {
var out []types.ModelProviderRoute
for _, a := range agents {
prefix := "/" + string(a.Type())
for _, route := range a.ModelProviderRoutes() {
out = append(out, types.ModelProviderRoute{
WirePath: prefix + route.WirePath,
UpstreamPath: route.UpstreamPath,
})
}
}
return out
}
52 changes: 52 additions & 0 deletions lib/batches/codingagent/codingagent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package codingagent_test

import (
"errors"
"testing"

"github.com/kballard/go-shellquote"

batcheslib "github.com/sourcegraph/sourcegraph/lib/batches"
"github.com/sourcegraph/sourcegraph/lib/batches/codingagent"
"github.com/sourcegraph/sourcegraph/lib/batches/template"
)

func TestRenderRunCommand_unknownType(t *testing.T) {
agentStep := &batcheslib.CodingAgentStep{Type: "nope", Prompt: "x"}
_, err := codingagent.RenderRunCommand(agentStep, "https://example/", &template.StepContext{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, codingagent.ErrUnknownType) {
t.Fatalf("expected ErrUnknownType, got %v", err)
}
}

// TestRenderRunCommand_promptShellQuoting ensures the rendered prompt is
// shell-quoted as a single argument even when it contains shell
// metacharacters (apostrophes, semicolons), so prompt text can't break out
// into the host shell.
func TestRenderRunCommand_promptShellQuoting(t *testing.T) {
const repoName = `github.com/sourcegraph/sourcegraph`
prompt := "You're working in the ${{ repository.name }} repository.\n" +
"Add a README section describing the project; don't touch existing files."
agentStep := &batcheslib.CodingAgentStep{
Type: batcheslib.CodingAgentTypeCodex,
Prompt: prompt,
}
stepCtx := &template.StepContext{Repository: template.Repository{Name: repoName}}
cmd, err := codingagent.RenderRunCommand(agentStep, "https://example/", stepCtx)
if err != nil {
t.Fatal(err)
}

tokens, err := shellquote.Split(cmd)
if err != nil {
t.Fatal(err)
}
wantPrompt := "You're working in the " + repoName + " repository.\n" +
"Add a README section describing the project; don't touch existing files."
if got := tokens[len(tokens)-1]; got != wantPrompt {
t.Fatalf("prompt mismatch:\n got: %q\n want: %q", got, wantPrompt)
}
}
Loading
Loading