prompts: PromptCatalog versioned-prompt loader (closes #92)#99
Conversation
Every Glue agent re-implemented the same per-tool boilerplate: a json.Unmarshal of call.Arguments into a private struct, plus local textResult / errorResult helpers. Promote the pattern into the framework so tool definitions drop ~15 lines each and gain consistent malformed- argument handling. - glue.TextResult(string) and glue.ErrorResult(error) replace the duplicated helpers (the agent had its own copies; future agents would too). - glue.NewTool[T any](spec, fn) decodes ToolCall.Arguments into T, treats empty arguments as the zero value, and returns an error ToolResult on JSON decode failure so the loop never crashes on a malformed call. Migrate agents/glue-review/tools.go as the first internal consumer: three tools converted, the local textResult/errorResult helpers removed. Schema generation from T is intentionally out of scope; callers continue to pass Parameters explicitly. Verification: go build ./... # ok go vet ./... # ok go test ./... # ok (all packages green) Closes #88 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Summary
This branch introduces framework-level convenience helpers (typed tools, event writer/log helpers, provider failover registry, versioned prompt catalog, and fs/git tool packages), while converting the internal review agent to consume them.
Issues
-
[critical] failover.go:60 — The goroutine reading
firstand forwardingchdoes not propagatectxcancellation; if the consumer stops readingout, the goroutine leaks and blocks onout<-. Fix: In failover.go, change the forwarding goroutine to select onctx.Done()in addition torange ch, and exit early if the context is cancelled. -
[critical] prompts/catalog.go:73 —
NewCatalogdoesn't defend against paths with interior..that don't split on/;SafeJoinis used inReadFileToolbut not in the catalog itself, so a caller could request version"../etc/passwd"which would be joined into the fs path traversal. Fix: In prompts/catalog.go, validateversioninGetby rejecting any string containing..or path separators before constructing the file path. -
[major] failover.go:55 —
failoverProviderstores the passed slice directly without copying; callers who mutate the slice after construction would affect the provider's behavior. Fix: In failover.go, copy theprovsslice into a new slice inWithFailover:provs: append([]Provider(nil), provs...). -
[major] providers/registry.go:84-91 —
KeyAvailableusesos.Getenvwhich observes the current process environment without any isolation; this makes it impossible to unit test without mutating global state. Thet.Setenvin tests works, but the design comment says "no I/O or credential side effects," yetos.Getenvis a read side-effect. Fix: In providers/registry.go, add aGetenv func(string) stringfield toFactory(defaulting toos.Getenv) so that tests can inject a mock environment without touching process globals.
Suggestions
-
[minor] session.go:208-210 —
auxEmitsruns afterconfig.emit, so if anauxEmitpanics, the user-providedWithEventshandler hasn't fired yet. This is subtle but documented; consider reversing order or documenting it explicitly. Fix: In session.go, add a comment above the emission loop documenting the exact ordering:config.emitfires before anyauxEmits. -
[minor] event_helpers.go:23,39 — The helpers silently ignore errors from
io.WriteStringandfmt.Fprintf. This is documented, but it means a partial write could go unnoticed. For debuggability, consider allowing an optional error logger. Fix: No code change needed; add a design note in the package doc comment explaining why errors are intentionally dropped (convenience over reliability) and pointing toWithEventsfor error-handling needs. -
[minor] tool_helpers.go:37-43 —
NewToolunmarshals intovar args Teven whencall.Argumentsisnilor[]byte(nil), which works (zero value), but it would be more explicit to skip unmarshal whenlen(call.Arguments) == 0and just pass the zero value directly. The current code already does this. Fix: No change needed. -
[minor] tools/fs/blocklist.go:128-132 — The
Matchfunction lowercases the pattern for every path component check, butfilepath.Matchis case-sensitive on case-sensitive filesystems. This means a blocklist entry likeId_Rsawould not matchid_rsaon Linux, even though the design says "case-insensitive." Fix: In tools/fs/blocklist.go, addstrings.ToLower(clean)andstrings.ToLower(base)at the top ofMatchso the whole-path and basename checks are case-insensitive as documented, or clarify in the doc comment that only per-component matching is case-insensitive.
Looks good
- Excellent test coverage across all new packages with table-driven tests and edge cases (nil catalog, empty streams, malformed JSON args, blocklist hits/misses, safe join traversals).
- The
NewTool[T]generic helper cleanly eliminates thejson.Unmarshalboilerplate and provides consistent malformed-argument handling. - The provider registry with
init()self-registration is idiomatic Go and keeps the core package decoupled from provider-specific imports. - Versioned prompt catalog fails fast on construction with clear error messages listing available versions.
- Clear separation between core glue package and POSIX-coupled tools (fs/git) per ADR 0003.
Open questions
- Is the
failoverProvidergoroutine leak a concern in practice, given the typical consumer will read the stream to completion? Should there be an explicitClose()orStop()method onProviderfor lifecycle management? - The
KeyAvailabletests uset.Setenvwhich serializes all tests in the package. Is this a concern for CI performance, or is the registry small enough that it doesn't matter? - Are there plans to migrate
agents/glue-review/prompt.goto useprompts.NewCatalog, or is that intentionally left as a follow-up? The branch updates the README but doesn't touch the agent's prompt loading code.
🤖 Posted by glue-review.
| } | ||
| return "all providers failed: " + strings.Join(parts, "; ") | ||
| } | ||
|
|
There was a problem hiding this comment.
[critical] The goroutine reading first and forwarding ch does not propagate ctx cancellation; if the consumer stops reading out, the goroutine leaks and blocks on out<-
💡 AI prompt to fix
In failover.go, change the forwarding goroutine to select on `ctx.Done()` in addition to `range ch`, and exit early if the context is cancelled.
| version = c.defaultVersion | ||
| } | ||
| data, err := fs.ReadFile(c.fsys, path.Join(c.dir, version+".md")) | ||
| if err != nil { |
There was a problem hiding this comment.
[critical] NewCatalog doesn't defend against paths with interior .. that don't split on /; SafeJoin is used in ReadFileTool but not in the catalog itself, so a caller could request version "../etc/passwd" which would be joined into the fs path traversal
💡 AI prompt to fix
In prompts/catalog.go, validate `version` in `Get` by rejecting any string containing `..` or path separators before constructing the file path.
|
|
||
| func (e *FailoverError) Error() string { | ||
| parts := make([]string, 0, len(e.Attempts)) | ||
| for _, a := range e.Attempts { |
There was a problem hiding this comment.
[major] failoverProvider stores the passed slice directly without copying; callers who mutate the slice after construction would affect the provider's behavior
💡 AI prompt to fix
In failover.go, copy the `provs` slice into a new slice in `WithFailover`: `provs: append([]Provider(nil), provs...)`.
| sort.Strings(out) | ||
| return out | ||
| } | ||
|
|
There was a problem hiding this comment.
[major] 91 — KeyAvailable uses os.Getenv which observes the current process environment without any isolation; this makes it impossible to unit test without mutating global state. The t.Setenv in tests works, but the design comment says "no I/O or credential side effects," yet os.Getenv is a read side-effect
💡 AI prompt to fix
In providers/registry.go, add a `Getenv func(string) string` field to `Factory` (defaulting to `os.Getenv`) so that tests can inject a mock environment without touching process globals.
| if config.emit != nil { | ||
| config.emit(event) | ||
| } | ||
| for _, aux := range config.auxEmits { |
There was a problem hiding this comment.
[minor] 210 — auxEmits runs after config.emit, so if an auxEmit panics, the user-provided WithEvents handler hasn't fired yet. This is subtle but documented; consider reversing order or documenting it explicitly
💡 AI prompt to fix
In session.go, add a comment above the emission loop documenting the exact ordering: `config.emit` fires before any `auxEmits`.
| // from T is intentionally out of scope. | ||
| func NewTool[T any](spec ToolSpec, fn func(ctx context.Context, args T) (ToolResult, error)) Tool { | ||
| return Tool{ | ||
| ToolSpec: spec, |
There was a problem hiding this comment.
[minor] 43 — NewTool unmarshals into var args T even when call.Arguments is nil or []byte(nil), which works (zero value), but it would be more explicit to skip unmarshal when len(call.Arguments) == 0 and just pass the zero value directly. The current code already does this
💡 AI prompt to fix
No change needed.
| lowPat := strings.ToLower(pat) | ||
| if ok, _ := filepath.Match(lowPat, strings.ToLower(base)); ok { | ||
| return true, pat | ||
| } |
There was a problem hiding this comment.
[minor] 132 — The Match function lowercases the pattern for every path component check, but filepath.Match is case-sensitive on case-sensitive filesystems. This means a blocklist entry like Id_Rsa would not match id_rsa on Linux, even though the design says "case-insensitive."
💡 AI prompt to fix
In tools/fs/blocklist.go, add `strings.ToLower(clean)` and `strings.ToLower(base)` at the top of `Match` so the whole-path and basename checks are case-insensitive as documented, or clarify in the doc comment that only per-component matching is case-insensitive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote the path-safety / git-shell-out helpers that agents/glue-review inlined into shared, audited extension packages. ADR 0003 was approved months ago but no implementation landed; the agent's tools.go and blocklist.go are empirical proof of the API shape, so port them. tools/fs: - SafeJoin(base, rel) — traversal + symlink defense - Truncate(s, max) — output cap with marker - Blocklist (Default/Merge/Match) — three-way pattern match (whole-path, basename, components, case-insensitive) - ReadFileTool(opts) — ready-to-register read tool composed of the above tools/git: - RunGit(ctx, opts, args...) — PATH lookup, configurable timeout (default 15s), stderr captured into the error - BuildPathspec(includes, excludes) — Git pathspec construction with the catch-all-include rule - DiffBranchTool(opts) — git_diff_branch tool - LogBranchTool(opts) — git_log_branch tool Both packages live outside the core glue package per ADR 0003 so the harness stays free of POSIX coupling. They depend on glue (for Tool / ToolSpec / ToolResult and the new NewTool[T] helper from #88); the core does not depend on them. ADR 0003 marked Implemented; design.md package boundaries updated. Migrating agents/glue-review to use these packages is filed as a follow-up so this PR stays single-purpose and reviewable. Verification: go build ./... # ok go vet ./... # ok go test ./... # ok go test ./tools/fs ./tools/git # ok (table tests + scratch-repo end-to-end) Closes #89 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stop forcing agents to hand-code provider failover. Today an agent that
wants nvidia → openrouter → gemini failover maintains a private map of
provider → env var, hand-codes per-attempt session ids to avoid
transcript poisoning, and buffers streamed text per attempt. Move the
boring parts into the framework.
New driver-style registry at providers/registry.go:
- providers.Register(name, Factory) — providers self-register in init()
- providers.New(name) → (Provider, defaultModel, envKey, error)
- providers.Lookup(name), providers.Known() (sorted), providers.KeyAvailable(name)
- Case-insensitive lookup; unknown name returns an error listing the
registered names
Each shipped provider exposes EnvKey + DefaultModel constants and
self-registers from init() (gemini, nvidia, openrouter). Importing
`_ "github.com/erain/glue/providers/<name>"` makes the name resolvable.
WithFailover wrapper in package glue:
- glue.WithFailover(provs ...Provider) Provider
- Tries each provider in order; falls through on Stream error,
immediate ProviderEventError, or empty stream
- Commits to a provider once any non-error event is observed —
preserves the loop's "no half-streamed transcripts on retry"
invariant, no mid-stream recovery
- All-providers-failed surfaces as *FailoverError with per-provider
attempts (errors.As compatible)
README: new "Provider failover" subsection. design.md: provider
registry listed under package boundaries.
Migrating agents/glue-review off its hand-coded failover is filed as a
follow-up so this PR stays single-purpose.
Verification:
go build ./... # ok
go vet ./... # ok
go test . ./loop ./providers/... ./tools/... # ok (all green)
go test ./agents/glue-review # one live test
# rate-limited
# by NVIDIA (HTTP 429),
# not a regression
Closes #90
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the canonical "switch e.Type { case EventTextDelta: ... }" block
that every CLI agent writes with two one-line options.
- glue.WithStreamWriter(io.Writer) mirrors EventTextDelta.Delta.
- glue.WithToolLogger(io.Writer) writes "[tool] <name>\n" on
EventToolStart.
- Both nil-safe (nil writer = no-op so callers can pass conditional
writers without branching) and silently drop writer errors —
documented as convenience options, not delivery-guaranteed pipes.
- Compose additively with WithEvents and each other via a new
promptConfig.auxEmits slice — installing one does not displace any
other handler. WithEvents continues to use the existing single-emit
field with last-wins semantics.
README "Streaming events" subsection rewritten to lead with the
helpers; the WithEvents path is kept as the richer-formatting fallback.
Verification:
go build ./... # ok
go vet ./... # ok
go test . ./loop ./providers/... ./tools/... # ok (all green)
go test . -run "Stream|ToolLogger|StreamHelpers" # ok
Closes #91
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote agents/glue-review/prompt.go into a small reusable framework helper. Versioning prompts behind an embed.FS is a recurring pattern (A/B test prompts, roll back without rebuild) and the agent's implementation already encodes the right error semantics — unknown version → list available, never silent fallback. New package github.com/erain/glue/prompts: - NewCatalog(fsys fs.FS, dir, defaultVersion) (*Catalog, error) validates that defaultVersion exists at construction time so misconfiguration fails fast. - Get(version) (string, error) — empty version uses the default; unknown returns an error listing every available version. - Versions() (sorted), Default() - Nil-safe getters (return nil/"" / typed error) Returned bodies are right-trimmed of newlines and re-suffixed with a single newline so callers can append further instructions without double-blank-line drift — matches the agent's existing format. Templating / variable substitution remain out of scope: the catalog returns raw bytes; rendering is the caller's job. README: new "Versioned prompts" subsection. Migration of agents/glue-review/prompt.go onto this catalog filed as a follow-up. Verification: go build ./... # ok go vet ./... # ok go test ./prompts -count=1 # ok (8/8) Closes #92 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
380521f to
d328137
Compare
Summary
github.com/erain/glue/promptswithCatalog(NewCatalog,Get,Versions,Default).agents/glue-review/prompt.gopattern: A/B-test prompts viaembed.FS, roll back without rebuilding history, and reject unknown versions with an error that lists every available version (no silent fallback).NewCatalogvalidatesdefaultVersionexists at construction time so misconfiguration fails fast.agents/glue-review/prompt.goonto this catalog filed as a follow-up.Stacked on #95–#98. Closes #92.
Test plan
go build ./...go vet ./...go test ./prompts -count=1(8/8 passing — known/empty/unknown lookups, default-must-exist, missing-dir, Versions/Default getters, nil-catalog safety)🤖 Generated with Claude Code