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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md).

## Unreleased

- **Per-model capability registry + tool-owned prompt assembly
(`providers`, `loop`, `tools/*`, `cmd/glue`).** The providers
registry now carries declarative `Capabilities` per provider
(context window, parallel-tool safety, prompt variant, auto-continue
proneness; `providers.CapabilitiesFor(name)`), replacing
if-provider-name switches — the `glue` binary's Gemini auto-continue
gating now reads the registry. Tools own their prompt text: the new
`ToolSpec.PromptSnippet` / `PromptGuidelines` fields (set across
`tools/fs`, `tools/shell`, `tools/git`) feed
`coding.SystemPrompt(tools, variant)`, which assembles a coding
system prompt from the active toolset — one line per tool plus
deduplicated guidelines, in a terse variant for frontier models
(gemini, codex) and an explicit variant for open-weight models. The
`glue` binary previously ran `--coding` with **no** system prompt;
it now gets the assembled one, and the prompt can never drift from
the registered toolset (snapshot-tested)
([docs/coding-harness-roadmap.md](docs/coding-harness-roadmap.md)
P1.5). Closes #345.

- **Loop guardrails (`loop`).** Two graduated detectors now watch every
tool round, on by default (`RunRequest.Guardrails`, zero value =
defaults; `Disabled` opts out): repeating the **same tool call with
Expand Down
15 changes: 9 additions & 6 deletions cmd/glue/goalcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,16 @@ func goalCommand(ctx context.Context, args []string, stdin io.Reader, stdout, st
case *coding:
permission = newLocalPromptPermission(stdin, stderr)
}
systemPrompt, autoContinue := capabilityDefaults(resolvedProvider, tools, *coding)
agent := glue.NewAgent(glue.AgentOptions{
Provider: providerImpl,
Model: normalizeModel(effectiveModel),
Tools: tools,
Store: store,
WorkDir: *workDir,
Permission: permission,
Provider: providerImpl,
Model: normalizeModel(effectiveModel),
Tools: tools,
Store: store,
WorkDir: *workDir,
Permission: permission,
SystemPrompt: systemPrompt,
AutoContinue: autoContinue,
})

spec := glue.GoalSpec{
Expand Down
35 changes: 23 additions & 12 deletions cmd/glue/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/erain/glue"
"github.com/erain/glue/providers"
filestore "github.com/erain/glue/stores/file"
toolscoding "github.com/erain/glue/tools/coding"

// Register the shipped providers so they resolve through the
// providers registry by name (--provider). Importing for side
Expand Down Expand Up @@ -173,22 +174,32 @@ func newAgent(newProvider providerFactory, cfg agentConfig) (*glue.Agent, error)
if err != nil {
return nil, err
}
systemPrompt, autoContinue := capabilityDefaults(cfg.Provider, cfg.Tools, cfg.Coding)
return glue.NewAgent(glue.AgentOptions{
Provider: provider,
Model: normalizeModel(cfg.Model),
Tools: append([]glue.Tool(nil), cfg.Tools...),
Store: filestore.New(cfg.StoreDir),
WorkDir: cfg.WorkDir,
Permission: cfg.Permission,
// Gemini is prone to the narrate-then-stop stall ("I will now
// edit the file." with no tool call); the loop's bounded
// "Please continue." nudge recovers it. Other providers keep
// the default behavior until a capability registry makes this
// per-model (#345).
AutoContinue: cfg.Provider == "gemini" && len(cfg.Tools) > 0,
Provider: provider,
Model: normalizeModel(cfg.Model),
Tools: append([]glue.Tool(nil), cfg.Tools...),
Store: filestore.New(cfg.StoreDir),
WorkDir: cfg.WorkDir,
Permission: cfg.Permission,
SystemPrompt: systemPrompt,
AutoContinue: autoContinue,
}), nil
}

// capabilityDefaults derives capability-driven agent settings from the
// providers registry: the coding system prompt is assembled from the
// active toolset in the provider's preferred variant, and the
// narrate-then-stop nudge is enabled only for providers that declare
// the stall (today: gemini).
func capabilityDefaults(providerName string, tools []glue.Tool, coding bool) (systemPrompt string, autoContinue bool) {
caps := providers.CapabilitiesFor(providerName)
if coding && len(tools) > 0 {
systemPrompt = toolscoding.SystemPrompt(tools, caps.PromptVariant)
}
return systemPrompt, caps.AutoContinue && len(tools) > 0
}

func normalizeModel(model string) string {
return strings.TrimPrefix(model, "gemini/")
}
Expand Down
18 changes: 12 additions & 6 deletions cmd/glue/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ type agentConfig struct {
WorkDir string
Tools []glue.Tool
Permission glue.Permission
// Coding selects the assembled coding system prompt (built from
// the active toolset in the provider's preferred variant).
Coding bool
}

func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, newProvider providerFactory) error {
Expand Down Expand Up @@ -190,13 +193,16 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W
return err
}
storeImpl := filestore.New(*storeDir)
systemPrompt, autoContinue := capabilityDefaults(providerName, tools, *coding)
agent := glue.NewAgent(glue.AgentOptions{
Provider: providerImpl,
Model: normalizeModel(effectiveModel),
Tools: append([]glue.Tool(nil), tools...),
Store: storeImpl,
WorkDir: *workDir,
Permission: permission,
Provider: providerImpl,
Model: normalizeModel(effectiveModel),
Tools: append([]glue.Tool(nil), tools...),
Store: storeImpl,
WorkDir: *workDir,
Permission: permission,
SystemPrompt: systemPrompt,
AutoContinue: autoContinue,
})

if interactive {
Expand Down
1 change: 1 addition & 0 deletions cmd/glue/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func serveCommand(ctx context.Context, args []string, stdout io.Writer, stderr i
StoreDir: *storeDir,
WorkDir: *workDir,
Tools: tools,
Coding: *coding,
})
if err != nil {
return err
Expand Down
11 changes: 11 additions & 0 deletions loop/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ type ToolSpec struct {
RequiresPermission bool `json:"-"`
PermissionAction string `json:"-"`
PermissionTarget func(ToolCall) string `json:"-"`

// PromptSnippet is a one-line description of the tool for system
// prompts assembled from the active toolset (the pi pattern: the
// tool owns its own prompt text, so prompt and toolset cannot
// drift). Empty means the tool is omitted from assembled prompts.
PromptSnippet string `json:"-"`

// PromptGuidelines are usage rules contributed to an assembled
// system prompt only while this tool is registered. Deduplicated
// across tools at assembly time.
PromptGuidelines []string `json:"-"`
}

// ToolExecutor runs a tool call locally and returns a normalized result.
Expand Down
6 changes: 6 additions & 0 deletions providers/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ func init() {
// No env key: subscription auth lives in auth.json, not an env
// var. providers.KeyAvailable("codex") will always report false
// — agents should probe auth.LoadTokens instead.
Capabilities: providers.Capabilities{
// gpt-5-codex: 400k window, frontier model, terse steering.
ContextWindow: 400_000,
ParallelTools: true,
PromptVariant: "terse",
},
})
}

Expand Down
9 changes: 9 additions & 0 deletions providers/gemini/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ func init() {
New: func() loop.Provider { return New(Options{}) },
DefaultModel: DefaultModel,
EnvKey: EnvKey,
Capabilities: providers.Capabilities{
// Gemini 3.x Pro: 1M-token window; frontier model that
// prefers terse steering; prone to the narrate-then-stop
// stall the loop's AutoContinue nudge recovers.
ContextWindow: 1_048_576,
ParallelTools: true,
PromptVariant: "terse",
AutoContinue: true,
},
})
}

Expand Down
7 changes: 7 additions & 0 deletions providers/nvidia/nvidia.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ func init() {
New: func() loop.Provider { return New(Options{}) },
DefaultModel: DefaultModel,
EnvKey: EnvKey,
Capabilities: providers.Capabilities{
// Open-weight hosting; window varies by model — 128k is a
// safe floor. Default (explicit) prompt variant; sequential
// tools: open-weight function calling is less reliable
// under concurrency.
ContextWindow: 131_072,
},
})
}

Expand Down
5 changes: 5 additions & 0 deletions providers/openrouter/openrouter.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func init() {
New: func() loop.Provider { return New(Options{}) },
DefaultModel: DefaultModel,
EnvKey: EnvKey,
Capabilities: providers.Capabilities{
// Aggregator of mostly open-weight models; window varies —
// 128k is a safe floor. Default (explicit) prompt variant.
ContextWindow: 131_072,
},
})
}

Expand Down
38 changes: 38 additions & 0 deletions providers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ import (
"github.com/erain/glue/loop"
)

// Capabilities records harness-relevant facts about a provider's
// models, declared at registration instead of scattered through
// if-provider-name switches. The zero value means "unknown / assume
// nothing": consumers must treat absent capabilities conservatively.
type Capabilities struct {
// ContextWindow is the default model's context window in tokens.
// Zero means unknown.
ContextWindow int

// ParallelTools reports whether tool calls from one assistant turn
// are safe to execute concurrently against this provider's models.
ParallelTools bool

// PromptVariant selects the system-prompt flavor assembled for
// this provider's models: "terse" for frontier models that need
// minimal steering, "" for the default (more explicit) variant.
PromptVariant string

// AutoContinue reports that the provider's models are prone to the
// narrate-then-stop stall and benefit from the loop's bounded
// "Please continue." nudge.
AutoContinue bool
}

// Factory describes one registered provider.
type Factory struct {
// New returns a fresh provider configured with package defaults
Expand All @@ -33,6 +57,20 @@ type Factory struct {
// EnvKey is the environment variable the provider reads when
// APIKey is empty. Used by KeyAvailable.
EnvKey string

// Capabilities declares harness-relevant facts about the
// provider's models. Optional; the zero value means unknown.
Capabilities Capabilities
}

// CapabilitiesFor returns the registered capabilities for name, or the
// zero value when the provider is unknown or declared none.
func CapabilitiesFor(name string) Capabilities {
f, ok := Lookup(name)
if !ok {
return Capabilities{}
}
return f.Capabilities
}

var (
Expand Down
18 changes: 18 additions & 0 deletions providers/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,21 @@ func TestRegistry_CaseInsensitive(t *testing.T) {
t.Fatal("Lookup must be case-insensitive on uppercase too")
}
}

func TestCapabilitiesForUnknownProvider(t *testing.T) {
caps := CapabilitiesFor("no-such-provider")
if caps != (Capabilities{}) {
t.Fatalf("caps = %#v, want zero value", caps)
}
}

func TestCapabilitiesForRegistered(t *testing.T) {
Register("caps-test", Factory{
DefaultModel: "m",
Capabilities: Capabilities{ContextWindow: 42, ParallelTools: true, PromptVariant: "terse", AutoContinue: true},
})
caps := CapabilitiesFor("caps-test")
if caps.ContextWindow != 42 || !caps.ParallelTools || caps.PromptVariant != "terse" || !caps.AutoContinue {
t.Fatalf("caps = %#v", caps)
}
}
67 changes: 67 additions & 0 deletions tools/coding/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package coding

import (
"strings"

"github.com/erain/glue"
)

// PromptVariantTerse selects the minimal system-prompt flavor for
// frontier models that need little steering. The empty string selects
// the default (more explicit) variant for open-weight models.
const PromptVariantTerse = "terse"

const terseIntro = `You are a coding agent operating directly in the user's workspace. Make the requested changes using the tools below, verify them (build/tests) when possible, and keep responses concise.`

const defaultIntro = `You are a coding agent operating directly in the user's workspace. Work in small, verifiable steps:

1. Read the relevant files before changing them.
2. Make focused edits with the editing tools — do not echo whole files into chat.
3. Verify your changes by running builds or tests when a shell tool is available.
4. Report what you changed and how you verified it, concisely.

Use one tool call when one suffices; stop and ask only when genuinely blocked.`

// SystemPrompt assembles a coding system prompt from the active
// toolset: one line per tool (from each tool's PromptSnippet) plus the
// deduplicated union of their PromptGuidelines. The prompt therefore
// can never drift from the tools actually registered — pi's
// tool-owned-prompt pattern. Tools without a snippet are omitted.
//
// variant selects the intro flavor: [PromptVariantTerse] for frontier
// models, "" for the default explicit variant (open-weight models
// benefit from the extra structure). Pick via
// providers.CapabilitiesFor(name).PromptVariant.
func SystemPrompt(tools []glue.Tool, variant string) string {
var b strings.Builder
if variant == PromptVariantTerse {
b.WriteString(terseIntro)
} else {
b.WriteString(defaultIntro)
}

var lines []string
var guidelines []string
seen := map[string]bool{}
for _, t := range tools {
if t.PromptSnippet != "" {
lines = append(lines, "- "+t.Name+": "+t.PromptSnippet)
}
for _, g := range t.PromptGuidelines {
if g == "" || seen[g] {
continue
}
seen[g] = true
guidelines = append(guidelines, "- "+g)
}
}
if len(lines) > 0 {
b.WriteString("\n\nTools:\n")
b.WriteString(strings.Join(lines, "\n"))
}
if len(guidelines) > 0 {
b.WriteString("\n\nGuidelines:\n")
b.WriteString(strings.Join(guidelines, "\n"))
}
return b.String()
}
Loading
Loading