glue: WithStreamWriter / WithToolLogger (closes #91)#98
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 several framework-level conveniences: typed tool builders (NewTool[T], TextResult, ErrorResult), event stream helpers (WithStreamWriter, WithToolLogger), a provider failover wrapper (WithFailover), a driver-style provider registry, and shared tools/fs and tools/git packages implementing ADR 0003.
Issues
-
[major] failover.go:49 —
failoverProvider.Streamleaks a goroutine and its output channel when it commits to a provider. Theoutchannel is buffered with 16 slots, but the helper goroutine will block forever if the caller abandons the returned channel without draining it (since nobody closesch). Additionally, ifchis never closed by the underlying provider, this goroutine leaks indefinitely. Also, theoutgoroutine receives fromchwithout actxcheck; if the caller cancels the context the goroutine still blocks on<-ch. Fix: Infailover.go, changefailoverProvider.Streamto select on an innerctx.Done()in the forwarding goroutine and, more importantly, propagate cancellation from the caller's context to the goroutine so it exits when the context is cancelled. Consider adding aclose(out)via adeferand aselect { case <-ctx.Done(): ... }in the forwarding loop. -
[major] event_helpers.go:36 —
WithToolLoggerusesfmt.Fprintf(w, "[tool] %s\n", e.ToolName)without sanitizinge.ToolName; if a malicious provider or tool injects newlines or format specifiers intoToolName, the output could be misleading or contain format-string-like behavior. Fix: Inevent_helpers.go, replacefmt.Fprintf(w, "[tool] %s\n", e.ToolName)withfmt.Fprint(w, "[tool] ", e.ToolName, "\n")orfmt.Fprintf(w, "[tool] %s\n", strings.ReplaceAll(e.ToolName, "%", "%%")). -
[critical] session.go:205 — When
config.emit(theWithEventshandler) andconfig.auxEmits(the helpers) are both present,dispatchEventis called vias.dispatchEvent(event)after both emit blocks, butconfig.emitalso runs beforeauxEmits. Ifconfig.emitpanics,auxEmitsanddispatchEventare skipped. More importantly,WithEventsdocumented "last-wins" semantics for its own field, butauxEmitsappends, so the ordering between a singleemitand multipleauxEmitsis now mixed without clear precedence. This is a subtle API contract issue. Fix: Document inevent_helpers.gothatWithStreamWriter/WithToolLoggerfire afterWithEventsbut beforedispatchEvent. Better yet, unify the pipelines insession.gosoemit,auxEmits, anddispatchEventare all called in a single deterministic order, with each wrapped in a defer/recover to prevent one panicking handler from killing the rest. -
[major] tools/fs/read.go:85 —
ReadFileToolusescapas a variable name, which shadows Go's built-incapfunction. While legal, it is poor practice and can confuse readers and linters. Fix: Intools/fs/read.go, rename the local variablecaptomaxorlimit. -
[major] tools/git/diff.go:83 — Same
capshadowing issue inDiffBranchTool. Fix: Intools/git/diff.go, rename the local variablecaptomaxorlimit.
Suggestions
-
[minor] failover.go:16 — The comment says
Returns a typed error implementing [FailoverError] when all providers fail, butFailoverErroris a concretestruct, not an interface, so "implementing" is slightly misleading. Fix: Infailover.go, reword the doc comment toReturns an error of type *FailoverError when all providers fail; use errors.As to inspect per-provider attempts. -
[minor] providers/registry.go:38 —
Factory.Newreturnsloop.Providerbut the nameNewis ambiguous; the registry is provider-scoped so it's clear, but a more descriptive name would help. This is fine as-is, but considerCreatein a future refactor. No fix required. -
[minor] tools/fs/blocklist.go:111 —
Matchdoes three separatefilepath.Matchcalls per pattern. For large blocklists or deeply nested paths this is O(n * 3) with repeated string allocations forlowPat. In practice blocklists are small, but consider caching lowered patterns. Not actionable here. -
[minor] tools/fs/fs.go:39 —
SafeJoincallsfilepath.Abs(base), which does I/O (Getwd) on every call. For high-volume tool calls this could be a bottleneck. Consider accepting an already-absoluted base path inReadFileOptionsor caching the resolved base. Fix: Intools/fs/fs.go, document thatbaseshould ideally be an absolute path to avoid repeatedGetwdcalls, or add a small cache inReadFileToolthat resolvesWorkDironce at construction time. -
[minor] event_helpers_test.go:109 — The
TestStreamHelpers_ComposeWithWithEventstest assertsevCount == 0as a failure condition, but it doesn't assert a specific expected count, which could mask under/over-firing. Fix: Inevent_helpers_test.go, assertevCountequals the expected number of events (e.g., 4) instead of just> 0. -
[minor] tool_helpers.go:38 —
NewToolhardcodes the error message formatdecode arguments for tool %q: %w. If the tool name is long or contains special characters, this is fine, but the error is not prefixed with the package name (e.g.,glue:). The rest of the package usesglue:prefixes in some places but not in tool errors. This is inconsistent but not harmful. No fix required. -
[minor] providers/registry.go:47 —
Registeroverwrites silently. This is documented as "last importer wins", but in practice it means two providers accidentally registering under the same name will silently hide one. Consider logging or panicking on overwrite during testing. Not actionable in this PR.
Looks good
- The typed
NewTool[T]helper removes significant boilerplate from agent tool definitions while keeping JSON schema generation out of scope — a clean, pragmatic boundary. WithFailovercorrectly preserves the "commit after first non-error event" invariant, which is the right semantic for LLM streaming.- The
tools/fsandtools/gitpackages cleanly separate POSIX concerns from the coregluepackage, matching ADR 0003 exactly. - Comprehensive test coverage for all new public APIs, including scratch-repo end-to-end tests for the git tools.
- The provider registry uses cheap factory registration in
init(), avoiding I/O side effects at import time.
Open questions
- For
WithFailover, is there a plan to expose metrics or hooks (e.g.,OnAttempt) so callers can log which provider was selected? It would be useful for debugging but is not blocking. - Should
ReadFileToolsupport binary files with a base64 escape hatch, or is UTF-8-only the intentional permanent boundary? - The
agents/glue-review/tools.gostill contains the old inline implementations (now wrapped inNewTool). Is the migration totools/fsandtools/gitpackages tracked for a follow-up, or should this PR also updateagents/glue-reviewto use the new shared packages? The latter seems like a natural cleanup to include.
🤖 Posted by glue-review.
|
|
||
| // FailoverAttempt is one provider's result inside a [FailoverError]. | ||
| type FailoverAttempt struct { | ||
| Index int |
There was a problem hiding this comment.
[major] failoverProvider.Stream leaks a goroutine and its output channel when it commits to a provider. The out channel is buffered with 16 slots, but the helper goroutine will block forever if the caller abandons the returned channel without draining it (since nobody closes ch). Additionally, if ch is never closed by the underlying provider, this goroutine leaks indefinitely. Also, the out goroutine receives from ch without a ctx check; if the caller cancels the context the goroutine still blocks on <-ch
💡 AI prompt to fix
In `failover.go`, change `failoverProvider.Stream` to select on an inner `ctx.Done()` in the forwarding goroutine and, more importantly, propagate cancellation from the caller's context to the goroutine so it exits when the context is cancelled. Consider adding a `close(out)` via a `defer` and a `select { case <-ctx.Done(): ... }` in the forwarding loop.
| // "[tool] <name>\n". Composes additively with other event-related | ||
| // options. Errors from the writer are silently dropped; nil w is a | ||
| // no-op. | ||
| func WithToolLogger(w io.Writer) PromptOption { |
There was a problem hiding this comment.
[major] WithToolLogger uses fmt.Fprintf(w, "[tool] %s\n", e.ToolName) without sanitizing e.ToolName; if a malicious provider or tool injects newlines or format specifiers into ToolName, the output could be misleading or contain format-string-like behavior
💡 AI prompt to fix
In `event_helpers.go`, replace `fmt.Fprintf(w, "[tool] %s\n", e.ToolName)` with `fmt.Fprint(w, "[tool] ", e.ToolName, "\n")` or `fmt.Fprintf(w, "[tool] %s\n", strings.ReplaceAll(e.ToolName, "%", "%%"))`.
| @@ -204,6 +205,9 @@ func (s *Session) Prompt(ctx context.Context, text string, options ...PromptOpti | |||
| if config.emit != nil { | |||
There was a problem hiding this comment.
[critical] When config.emit (the WithEvents handler) and config.auxEmits (the helpers) are both present, dispatchEvent is called via s.dispatchEvent(event) after both emit blocks, but config.emit also runs before auxEmits. If config.emit panics, auxEmits and dispatchEvent are skipped. More importantly, WithEvents documented "last-wins" semantics for its own field, but auxEmits appends, so the ordering between a single emit and multiple auxEmits is now mixed without clear precedence. This is a subtle API contract issue
💡 AI prompt to fix
Document in `event_helpers.go` that `WithStreamWriter`/`WithToolLogger` fire *after* `WithEvents` but before `dispatchEvent`. Better yet, unify the pipelines in `session.go` so `emit`, `auxEmits`, and `dispatchEvent` are all called in a single deterministic order, with each wrapped in a defer/recover to prevent one panicking handler from killing the rest.
| return glue.ErrorResult(err), nil | ||
| } | ||
| return glue.TextResult(Truncate(string(data), cap)), nil | ||
| }, |
There was a problem hiding this comment.
[major] ReadFileTool uses cap as a variable name, which shadows Go's built-in cap function. While legal, it is poor practice and can confuse readers and linters
💡 AI prompt to fix
In `tools/fs/read.go`, rename the local variable `cap` to `max` or `limit`.
| gitArgs := []string{"diff", "--no-color", base + "...HEAD"} | ||
| if len(opts.Pathspec) > 0 { | ||
| gitArgs = append(gitArgs, "--") | ||
| gitArgs = append(gitArgs, opts.Pathspec...) |
There was a problem hiding this comment.
[major] Same cap shadowing issue in DiffBranchTool
💡 AI prompt to fix
In `tools/git/diff.go`, rename the local variable `cap` to `max` or `limit`.
| EnvKey string | ||
| } | ||
|
|
||
| var ( |
There was a problem hiding this comment.
[minor] Factory.New returns loop.Provider but the name New is ambiguous; the registry is provider-scoped so it's clear, but a more descriptive name would help. This is fine as-is, but consider Create in a future refactor. No fix required.
| func (b Blocklist) Match(rel string) (bool, string) { | ||
| clean := strings.TrimSpace(rel) | ||
| if clean == "" { | ||
| return false, "" |
There was a problem hiding this comment.
[minor] Match does three separate filepath.Match calls per pattern. For large blocklists or deeply nested paths this is O(n * 3) with repeated string allocations for lowPat. In practice blocklists are small, but consider caching lowered patterns. Not actionable here.
| } | ||
| candidate := filepath.Clean(filepath.Join(absBase, rel)) | ||
| rel2, err := filepath.Rel(absBase, candidate) | ||
| if err != nil { |
There was a problem hiding this comment.
[minor] SafeJoin calls filepath.Abs(base), which does I/O (Getwd) on every call. For high-volume tool calls this could be a bottleneck. Consider accepting an already-absoluted base path in ReadFileOptions or caching the resolved base
💡 AI prompt to fix
In `tools/fs/fs.go`, document that `base` should ideally be an absolute path to avoid repeated `Getwd` calls, or add a small cache in `ReadFileTool` that resolves `WorkDir` once at construction time.
| func NewTool[T any](spec ToolSpec, fn func(ctx context.Context, args T) (ToolResult, error)) Tool { | ||
| return Tool{ | ||
| ToolSpec: spec, | ||
| Execute: func(ctx context.Context, call ToolCall) (ToolResult, error) { |
There was a problem hiding this comment.
[minor] NewTool hardcodes the error message format decode arguments for tool %q: %w. If the tool name is long or contains special characters, this is fine, but the error is not prefixed with the package name (e.g., glue: ). The rest of the package uses glue: prefixes in some places but not in tool errors. This is inconsistent but not harmful. No fix required.
| // name overwrites — driver-style packages may register from init() and | ||
| // the last importer wins. | ||
| func Register(name string, f Factory) { | ||
| mu.Lock() |
There was a problem hiding this comment.
[minor] Register overwrites silently. This is documented as "last importer wins", but in practice it means two providers accidentally registering under the same name will silently hide one. Consider logging or panicking on overwrite during testing. Not actionable in this PR.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4bd143867
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| continue | ||
| } | ||
|
|
||
| first, ok := <-ch |
There was a problem hiding this comment.
Honor context cancellation before first provider event
The failover wrapper blocks on first, ok := <-ch without watching ctx.Done(), so if a provider returns a stream channel but never emits its first event (for example, a hung backend connection), the call cannot be canceled and never advances to fallback providers. In this state, Session.Prompt can hang indefinitely even when the caller’s context deadline expires.
Useful? React with 👍 / 👎.
| candidate := filepath.Clean(filepath.Join(absBase, rel)) | ||
| rel2, err := filepath.Rel(absBase, candidate) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| if strings.HasPrefix(rel2, "..") || rel2 == ".." { | ||
| return "", fmt.Errorf("path %q escapes work directory", rel) |
There was a problem hiding this comment.
Resolve symlinks before accepting joined file paths
SafeJoin only performs a lexical filepath.Rel check on the joined path and never resolves symlinks, so a path like repo/linked (where linked points outside base) is accepted and later opened by ReadFileTool. This bypasses the workspace boundary and allows reading files outside the intended directory despite the function contract claiming symlink-safe behavior.
Useful? React with 👍 / 👎.
| max = DefaultReadMaxBytes | ||
| } | ||
| workDir := opts.WorkDir | ||
| blocklist := opts.Blocklist |
There was a problem hiding this comment.
Default ReadFileTool to built-in sensitive-file blocklist
The constructor copies opts.Blocklist directly, so the zero-value options path leaves blocklist empty and blocklist.Match never rejects anything. That makes read_file able to return .env, key material, and credential files unless every caller remembers to pass fs.Default(), which contradicts the tool’s own description that it refuses secret-shaped files.
Useful? React with 👍 / 👎.
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>
b4bd143 to
5e95615
Compare
Summary
glue.WithStreamWriter(io.Writer)mirrorsEventTextDelta.Delta.glue.WithToolLogger(io.Writer)writes[tool] <name>\nonEventToolStart.WithEventsand each other via a newpromptConfig.auxEmitsslice — installing one does not displace any other handler.Stacked on #95, #96, #97. Closes #91.
Test plan
go build ./...go vet ./...go test . ./loop ./providers/... ./tools/...— all greenWithEvents🤖 Generated with Claude Code