Skip to content
Open
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
54 changes: 42 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ loop/REPO-NAME/config.yaml:
- Name of head-branch. Defaults to "main".
- Per-issue rate-limit ceiling. `rateLimitWindow` (Go duration, default `1h`) and
`rateLimitMax` (int >= 1, default `10`). See "Rate-Limit" below.
- `defaultPrompts` (bool, default `true`). When true (the default), the
built-in `plan` / `code` / `merge` prompts are merged into the loop for any
name not already supplied by a user file in `prompts/`. Set to false to
require every prompt to come from `prompts/` (an empty `prompts/` directory
then fails to load).

loop-data/REPO-NAME/issues:
- Directory, for each issue there is a git clone in sub-directory. Example directory 1234 is the git
Expand Down Expand Up @@ -315,14 +320,17 @@ distinct, the issue maps to exactly one prompt at each step.
Auto-merge is not a separate config flag — it is configured by **which prompts the operator
commits**. There is no second source of truth to keep in sync.

- **Auto-merge on** (default scaffold): keep `merge.md` in `prompts/` and the
`routes: { merge: loop/merge }` line in `code.md`. The code agent hands the PR off to the merge
agent, which rebases, fixes CI and merges.
- **Auto-merge off**: delete `merge.md` and drop the `routes: { merge: ... }` entry from
`code.md`. The code prompt then terminates at `loop/code-done` and a human reviews / merges the
PR by hand.
- **Auto-merge on** (default workflow): leave `prompts/` empty. The built-in
`code` routes to the built-in `merge`, which rebases, fixes CI and merges.
- **Auto-merge off**: drop a `prompts/code.md` whose front-matter omits
`routes: { merge: ... }` — for example one with `default: code` (to keep the
built-in body) plus an empty `routes:` map, or a full custom file. The code
prompt then terminates at `loop/code-done` and a human reviews / merges the
PR by hand. The unused built-in `merge` still loads but is only triggered if
a human manually adds `loop/merge` to an issue; to drop it from the loop
entirely, set `defaultPrompts: false` and ship your own prompts.

Fork `merge.md` if you want a different strategy. Common variants:
Drop a custom `prompts/merge.md` if you want a different strategy. Common variants:

- **Strict rebase** (default in the scaffold): linear history, force-push the PR branch when main
moves.
Expand Down Expand Up @@ -447,7 +455,10 @@ re-triggers by moving the issue back to the InputLabel.
agentloop creates the required issue labels in the forge if they are missing: each prompt's
InputLabel, InProcessLabel and OutputLabel (all `loop/*`), plus `NeedSupervisor`. This happens per
repo (e.g. on first run / startup for that config), so the operator does not have to create them by
hand.
hand. Every label is created (or updated) with a short description so a reader in the forge UI
knows what triggers each label and where to find the prompt body — built-in prompts say "override
at prompts/<name>.md" and user prompts say "edit prompts/<name>.md". Descriptions are capped at
100 characters (GitHub's per-label limit) and truncated with an ellipsis when they overflow.

This relies on forge being able to create labels, set labels on an issue, and post comments.
Confirmed: forge supports all of these via its Go interface (`Labels().Create`,
Expand Down Expand Up @@ -486,14 +497,33 @@ Run `agentloop init <repo-url>` to scaffold a new loop. It creates the git-track
```
loop/ # commit this in your deployment repo
agentloop/
config.yaml # repoURL, usernames, exclusionLabels, headBranch
config.yaml # repoURL, usernames, exclusionLabels, headBranch, defaultPrompts
acpx-config # agent + globalArgs: [--approve-all] (required)
prompts/
plan.md # front-matter: inputLabel, inProcessLabel, outputLabel, ...
code.md
prompts/ # empty by default; drop *.md here to override built-ins
loop-data/ # runtime: per-issue clones + NNNN.meta/, global lock
```

`agentloop init` does not write any prompt files: the default `plan` / `code` /
`merge` workflow is embedded in the binary, so a freshly-scaffolded loop runs
the full plan → code → merge pipeline with no further configuration. Drop a
file into `prompts/` to override one of the built-ins:

- **Replace the body and metadata wholesale.** A file named `prompts/plan.md`
fully replaces the built-in `plan` (the built-in's body, concurrency, timeout
and routes are discarded). The unmodified built-ins still run.
- **Keep the default body, change only the labels (or some routes).** Drop a
file with `default: plan` in its front-matter; the body, concurrency, timeout
and routes are inherited from the built-in, but every field you set
explicitly wins. The file may not also contain an inline body — that
combination is rejected as ambiguous.
- **Opt out entirely.** Set `defaultPrompts: false` in `config.yaml` and the
built-ins are no longer merged in; the loop then fails to load unless you
ship every prompt yourself.

The built-in prompt sources live in
[`internal/config/defaults/`](internal/config/defaults/) — copy one into
`prompts/` as a starting point for customization.

agentloop authenticates to the forge the same way the forge CLI does: a token from a
domain-specific environment variable (`GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `GITEA_TOKEN`,
`BITBUCKET_TOKEN`), a `FORGE_TOKEN` fallback, or forge's `~/.config/forge/config`. The token must be
Expand Down
104 changes: 4 additions & 100 deletions init.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (

forges "github.com/git-pkgs/forge"

"github.com/guettli/agentloop/internal/config"
"github.com/guettli/agentloop/internal/loop"
)

Expand Down Expand Up @@ -50,11 +49,8 @@ func initLoop(loopDir, dataDir, repoURL string) error {
}

files := map[string]string{
filepath.Join(dest, "config.yaml"): configYAML(repoURL, owner),
filepath.Join(dest, "acpx-config"): acpxConfig(),
filepath.Join(promptsDir, "plan.md"): planPromptMD(),
filepath.Join(promptsDir, "code.md"): codePromptMD(),
filepath.Join(promptsDir, "merge.md"): mergePromptMD(),
filepath.Join(dest, "config.yaml"): configYAML(repoURL, owner),
filepath.Join(dest, "acpx-config"): acpxConfig(),
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
Expand All @@ -64,6 +60,8 @@ func initLoop(loopDir, dataDir, repoURL string) error {

fmt.Printf("Created %s (commit it in git) and %s (runtime, git-ignored).\n", dest, dataDir)
fmt.Printf("Edit %s to set the allowed usernames, then run agentloop.\n", filepath.Join(dest, "config.yaml"))
fmt.Printf("The default plan/code/merge workflow is built in; drop files into %s to customize it.\n",
promptsDir)
return nil
}

Expand All @@ -88,97 +86,3 @@ globalArgs: [--approve-all]
# execArgs: [--model, sonnet] # exec-scoped flags, after "exec"
`
}

// promptMD builds a prompt file declaring all three labels explicitly from the
// recommended naming convention (loop/<name>, -in-process, -done).
func promptMD(name, extraFrontMatter, body string) string {
base := config.LabelNamespace + name
return fmt.Sprintf(`---
inputLabel: %s
inProcessLabel: %s
outputLabel: %s
%s---
%s`,
base, base+config.InProcessSuffix, base+config.DoneSuffix, extraFrontMatter, body)
}

func planPromptMD() string {
return promptMD("plan", "concurrency: 3\n", `You are a planning agent.

Read the issue description and write a concise, concrete implementation plan.
Your final message is posted back to the issue as a comment.

Do not change any code and do not open a pull request.
`)
}

func codePromptMD() string {
return promptMD("code", "concurrency: 1\nroutes:\n merge: loop/merge\n", `You are a coding agent working inside a clean checkout of this repository.

Implement the change requested in the issue description. Work only from the
issue title and body — there are no other instructions to read.

Guidelines:

- Keep the change focused: implement exactly what the issue asks, nothing more.
- Match the surrounding code's style, naming and structure. Read neighbouring
files before writing new code.
- Add or update tests when you change behaviour, and make sure the existing
checks pass.

When the work is done:

- Commit your work on a new branch named after the issue (for example
issue-<NNNN>-short-description).
- Open a pull request that references the issue (e.g. "Closes #<NNNN>") with a
short description of what changed and how you verified it.

Your final message is posted back to the issue as a comment, so summarise what
you did and link the pull request. End the message with the following marker
on its own line so agentloop hands the issue off to the merge prompt, which
will get CI green and merge the PR:

<!-- loop-route: merge -->
`)
}

func mergePromptMD() string {
return promptMD("merge", "concurrency: 1\ntimeout: 2h\nroutes:\n escalate: NeedSupervisor\n", `You are a merging agent. A coding agent has already opened a pull request that
closes this issue. Your job is to get its CI green and then merge it.

Work only from the issue title and body and the pull request that references
this issue — there are no other instructions to read.

Steps:

- Find the open pull request that closes this issue.
- If the PR branch is behind the configured head branch (typically `+"`main`"+`),
bring it up to date by rebasing onto that head branch and force-pushing the
branch. Prefer rebase over a merge commit so history stays linear; fall
back to "merge the head branch into the PR branch" only if the project's
merge strategy requires it (read the repo's merge settings if unsure).
- If the rebase has conflicts you cannot resolve mechanically, escalate
rather than guessing. Likewise, if the branch contains commits whose author
is not the agentloop user, escalate instead of force-pushing — a human may
be collaborating on the branch.
- Check the pull request's CI status. If checks are still running, wait for
them to finish (poll periodically) within your timeout budget.
- If CI passes, merge the pull request and you are done.
- If CI fails, check out the pull request's branch in the local clone, read
the failing job logs, fix the underlying problem, commit the fix on the same
branch and push. Then re-check CI.
- If the head branch has advanced again while you were fixing CI, repeat the
rebase step before re-checking CI.
- Do not open a second pull request: keep working on the existing one.

If CI cannot be made green after a reasonable effort (the failure is outside
your control — flaky infrastructure, a required external service, or a problem
you do not know how to fix), end your final message with the following marker
on its own line so the issue is escalated to a human:

<!-- loop-route: escalate -->

Your final message is posted back to the issue as a comment, so summarise what
you did and whether the pull request was merged.
`)
}
17 changes: 16 additions & 1 deletion init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,19 @@ func TestInitLoopScaffolds(t *testing.T) {
t.Fatalf("loop-data not created: %v", err)
}

// The generated loop must load and validate, with plan + code prompts.
// The scaffolded prompts/ directory is left empty: the default workflow
// lives in the binary, and the directory only exists as a hook for users
// who want to customize it.
promptsDir := filepath.Join(loopDir, "agentloop", "prompts")
entries, err := os.ReadDir(promptsDir)
if err != nil {
t.Fatalf("read prompts dir: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected prompts/ to be empty after init, got %v", entries)
}

// The generated loop must load and validate via the built-in defaults.
loaded, err := config.LoadLoop(filepath.Join(loopDir, "agentloop"))
if err != nil {
t.Fatalf("generated loop is invalid: %v", err)
Expand All @@ -38,6 +50,9 @@ func TestInitLoopScaffolds(t *testing.T) {
if byName[want] == nil {
t.Errorf("expected %q prompt, got %v", want, byName)
}
if !loaded.Builtin[want] {
t.Errorf("Builtin[%q] = false, want true", want)
}
}
// The default code prompt routes to the merge prompt so the PR is merged
// automatically once CI is green.
Expand Down
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ type Config struct {
// than 10 times per hour".
RateLimitWindow time.Duration
RateLimitMax int

// DefaultPrompts toggles whether the built-in plan/code/merge prompts are
// merged into the loop when not overridden by a user file. Defaults to
// true. Set to false to require all prompts to come from prompts/*.md.
DefaultPrompts bool
}

// configYAML is the strict on-disk shape of config.yaml. It is kept separate
Expand All @@ -60,6 +65,7 @@ type configYAML struct {
HeadBranch string `yaml:"headBranch"`
RateLimitWindow *string `yaml:"rateLimitWindow"`
RateLimitMax *int `yaml:"rateLimitMax"`
DefaultPrompts *bool `yaml:"defaultPrompts"`
}

// Load reads and validates config.yaml at path. Parsing is strict: unknown
Expand All @@ -86,6 +92,10 @@ func Load(path string) (*Config, error) {
HeadBranch: y.HeadBranch,
RateLimitWindow: DefaultRateLimitWindow,
RateLimitMax: DefaultRateLimitMax,
DefaultPrompts: true,
}
if y.DefaultPrompts != nil {
c.DefaultPrompts = *y.DefaultPrompts
}
if y.RateLimitWindow != nil {
d, err := time.ParseDuration(*y.RateLimitWindow)
Expand Down
29 changes: 29 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,35 @@ repoURL: https://github.com/guettli/agentloop
}
}

func TestDefaultPromptsTrueByDefault(t *testing.T) {
path := writeTemp(t, `
repoURL: https://github.com/guettli/agentloop
usernames: [guettli]
`)
c, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.DefaultPrompts {
t.Errorf("DefaultPrompts should default to true")
}
}

func TestDefaultPromptsExplicitFalse(t *testing.T) {
path := writeTemp(t, `
repoURL: https://github.com/guettli/agentloop
usernames: [guettli]
defaultPrompts: false
`)
c, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.DefaultPrompts {
t.Errorf("DefaultPrompts should be false when explicitly set")
}
}

func TestEmptyUsernameFails(t *testing.T) {
path := writeTemp(t, `
repoURL: https://github.com/guettli/agentloop
Expand Down
76 changes: 76 additions & 0 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package config

import (
"embed"
"fmt"
"path"
)

//go:embed defaults/*.md
var defaultFS embed.FS

// DefaultNames is the closed set of built-in prompt names, in the order they
// run in the default plan -> code -> merge workflow. Iteration order here is
// the order built-ins are appended to a loop's prompt set when not overridden.
var DefaultNames = []string{"plan", "code", "merge"}

// LoadDefaultPrompt parses one of the embedded built-in prompts by name (e.g.
// "plan"). It returns an error when name is not in DefaultNames.
func LoadDefaultPrompt(name string) (*Prompt, error) {
if !isDefaultName(name) {
return nil, fmt.Errorf("unknown default prompt %q (known: %v)", name, DefaultNames)
}
virtual := path.Join("<builtin>", name+".md")
content, err := defaultFS.ReadFile("defaults/" + name + ".md")
if err != nil {
return nil, fmt.Errorf("read embedded %s: %w", virtual, err)
}
return parsePrompt(name, virtual, content)
}

func isDefaultName(name string) bool {
for _, n := range DefaultNames {
if n == name {
return true
}
}
return false
}

// applyDefault resolves a `default:` front-matter directive by merging the
// embedded built-in's body / Concurrency / Timeout / Routes into p where the
// user's front-matter omitted them. The user's labels (and any explicitly set
// concurrency/timeout/etc.) always win. Mixing `default:` with an inline body
// is rejected as ambiguous.
func applyDefault(p *Prompt, fm *frontMatter, pathLabel string) error {
if fm.Default == "" {
return nil
}
if p.Body != "" {
return fmt.Errorf("invalid %s: cannot combine a body with default: %q (drop one)", pathLabel, fm.Default)
}
base, err := LoadDefaultPrompt(fm.Default)
if err != nil {
return fmt.Errorf("invalid %s: %w", pathLabel, err)
}
p.Body = base.Body
if fm.Concurrency == nil {
p.Concurrency = base.Concurrency
}
if fm.Timeout == nil {
p.Timeout = base.Timeout
}
if fm.MaxRestarts == nil {
p.MaxRestarts = base.MaxRestarts
}
if fm.MaxCIFixes == nil {
p.MaxCIFixes = base.MaxCIFixes
}
if len(fm.Routes) == 0 && len(base.Routes) > 0 {
p.Routes = make(map[string]string, len(base.Routes))
for k, v := range base.Routes {
p.Routes[k] = v
}
}
return nil
}
Loading