From dddf10bc362ae767a265bceb87d29d8103c679d8 Mon Sep 17 00:00:00 2001 From: kit-agent Date: Thu, 18 Jun 2026 13:32:59 +0300 Subject: [PATCH] feat(sdk): add Options.DebugLogger and WithDebugLogger option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today the SDK exposes a DebugLogger interface (pkg/kit/types.go) but no public path to install one — the only consumer of the field is the unexported kit.AgentConfig.toInternal() method, which itself is not reachable from outside the package. As a result, embedders that want to forward Kit's low-level engine + MCP tool plumbing debug output into their own logging system (slog, zap, charm/log, an in-app TUI panel, etc.) have no option but the on/off Debug bool, which always installs the built-in SimpleDebugLogger / BufferedDebugLogger. This change closes that gap on the supported Options / functional-option construction path: - pkg/kit/kit.go: add Options.DebugLogger DebugLogger. When non-nil it is used directly and the Debug bool is ignored; the supplied logger's IsDebugEnabled() controls whether downstream code emits messages. - pkg/kit/options.go: add WithDebugLogger(l DebugLogger) Option. - internal/kitsetup/setup.go: add AgentSetupOptions.DebugLogger and switch SetupAgent's logger selection so the caller-supplied logger wins unconditionally; otherwise the existing Debug + UseBufferedLogger branch picks the built-in implementation. No behaviour change when DebugLogger is nil. - pkg/kit/kit.go: wire opts.DebugLogger into setupOpts so the New() path threads it through. - pkg/kit/viper_isolation_test.go: add TestWithDebugLoggerPlumbing and TestWithDebugLoggerNilClears covering the option-to-field contract and later-options-override semantics consistent with the other With* helpers. - pkg/kit/README.md: list WithDebugLogger in the helper inventory. Notes: - kit.DebugLogger and tools.DebugLogger are structurally identical (LogDebug(string) / IsDebugEnabled() bool), so the SDK value flows into the internal field without a conversion. - This is purely additive on the SDK surface and does not touch kit.AgentConfig — that field already carried a DebugLogger, but the AgentConfig path is unreachable from outside the package today. --- internal/kitsetup/setup.go | 12 ++++++++- pkg/kit/README.md | 3 ++- pkg/kit/kit.go | 19 +++++++++++++- pkg/kit/options.go | 11 ++++++++ pkg/kit/viper_isolation_test.go | 46 +++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/internal/kitsetup/setup.go b/internal/kitsetup/setup.go index a02010dd..52a576ab 100644 --- a/internal/kitsetup/setup.go +++ b/internal/kitsetup/setup.go @@ -53,6 +53,11 @@ type AgentSetupOptions struct { // Debug enables debug logging. When zero-value, viper is consulted. // Only meaningful when ProviderConfig is also set. Debug bool + // DebugLogger, if non-nil, is used directly as the engine/MCP debug + // logger — overriding the built-in SimpleDebugLogger / BufferedDebugLogger + // selected by Debug + UseBufferedLogger. Callers supply this when they + // want to route debug output into their own logging system. + DebugLogger tools.DebugLogger // NoExtensions skips extension loading. When false, viper is consulted. // Only meaningful when ProviderConfig is also set. NoExtensions bool @@ -192,7 +197,12 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, // Create the appropriate debug logger. var debugLogger tools.DebugLogger var bufferedLogger *tools.BufferedDebugLogger - if debugEnabled { + switch { + case opts.DebugLogger != nil: + // Caller-supplied logger wins unconditionally. Its IsDebugEnabled() + // is the source of truth for whether downstream code emits messages. + debugLogger = opts.DebugLogger + case debugEnabled: if opts.UseBufferedLogger { bufferedLogger = tools.NewBufferedDebugLogger(true) debugLogger = bufferedLogger diff --git a/pkg/kit/README.md b/pkg/kit/README.md index fcaafb4a..836e66de 100644 --- a/pkg/kit/README.md +++ b/pkg/kit/README.md @@ -74,7 +74,8 @@ host, err := kit.NewAgent(ctx, Helpers: `WithModel`, `WithSystemPrompt`, `WithStreaming`, `WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`, `WithProviderAPIKey`, -`WithProviderURL`, `WithConfigFile`, `WithDebug`, and `Ephemeral`. `Option` is +`WithProviderURL`, `WithConfigFile`, `WithDebug`, `WithDebugLogger`, and +`Ephemeral`. `Option` is a plain `func(*Options)`, so you can define your own. For fields without a `With*` helper (`MCPConfig`, `InProcessMCPServers`, `SessionManager`, MCP task tuning) construct an `Options` value and call `kit.New`. diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 2b4aa9dd..e5f0a276 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -1047,9 +1047,25 @@ type Options struct { AutoCompact bool // Auto-compact when near context limit CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults) - // Debug enables debug logging for the SDK. + // Debug enables debug logging for the SDK. When DebugLogger is nil this + // flag selects between the default no-op SimpleDebugLogger (Debug=false) + // and the built-in console/buffered logger (Debug=true). When DebugLogger + // is non-nil this flag is ignored — the supplied logger's + // IsDebugEnabled() controls whether downstream code emits messages. Debug bool + // DebugLogger, if non-nil, routes low-level debug output from the engine + // and the MCP tool plumbing to a caller-supplied implementation. This is + // the SDK escape hatch for embedders that want to forward debug output + // into their own logging system (zap, slog, log/charm, an in-app TUI + // panel, etc.) instead of the built-in console logger. + // + // When nil (default) the Debug bool controls whether the built-in logger + // is installed. When non-nil this logger is used unconditionally and the + // Debug bool is ignored; the supplied logger's IsDebugEnabled() reports + // whether downstream code should bother formatting messages. + DebugLogger DebugLogger + // MCPAuthHandler handles OAuth authorization for remote MCP servers. // When set, remote transports (streamable HTTP, SSE) are configured // with OAuth support. If the server returns a 401, the handler is @@ -1514,6 +1530,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult), ProviderConfig: providerConfig, Debug: debug, + DebugLogger: opts.DebugLogger, NoExtensions: noExtensions, MaxSteps: maxSteps, StreamingEnabled: streaming, diff --git a/pkg/kit/options.go b/pkg/kit/options.go index d40c711a..8e940a9b 100644 --- a/pkg/kit/options.go +++ b/pkg/kit/options.go @@ -83,6 +83,17 @@ func WithConfigFile(path string) Option { return func(o *Options) { o.ConfigFile // WithDebug enables SDK debug logging. func WithDebug() Option { return func(o *Options) { o.Debug = true } } +// WithDebugLogger installs a caller-supplied [DebugLogger] for low-level +// engine and MCP tool plumbing output. When set this overrides the built-in +// logger selected by [WithDebug] — messages flow into the supplied logger +// unconditionally, and the logger's IsDebugEnabled reports whether downstream +// code should bother formatting them. Use this to forward Kit's debug output +// into your application's logging system (slog, zap, charm/log, an in-app +// panel, etc.). +func WithDebugLogger(l DebugLogger) Option { + return func(o *Options) { o.DebugLogger = l } +} + // Ephemeral configures an in-memory session with no persistence (equivalent to // Options.NoSession = true). func Ephemeral() Option { return func(o *Options) { o.NoSession = true } } diff --git a/pkg/kit/viper_isolation_test.go b/pkg/kit/viper_isolation_test.go index c0b4ba2a..4260f6ca 100644 --- a/pkg/kit/viper_isolation_test.go +++ b/pkg/kit/viper_isolation_test.go @@ -63,6 +63,52 @@ func TestOptionFunctionsPlumbing(t *testing.T) { } } +// recordingDebugLogger is a kit.DebugLogger used to verify WithDebugLogger +// plumbs the supplied logger into Options. It records each LogDebug call. +type recordingDebugLogger struct { + enabled bool + messages []string +} + +func (l *recordingDebugLogger) LogDebug(m string) { l.messages = append(l.messages, m) } +func (l *recordingDebugLogger) IsDebugEnabled() bool { return l.enabled } + +// TestWithDebugLoggerPlumbing verifies that kit.WithDebugLogger assigns the +// supplied logger to Options.DebugLogger. End-to-end propagation into the +// engine is covered indirectly by the existing kitsetup tests; this test +// pins the SDK-surface contract. +func TestWithDebugLoggerPlumbing(t *testing.T) { + l := &recordingDebugLogger{enabled: true} + o := &kit.Options{} + kit.WithDebugLogger(l)(o) + if o.DebugLogger == nil { + t.Fatal("WithDebugLogger: expected Options.DebugLogger to be set") + } + if o.DebugLogger != l { + t.Error("WithDebugLogger: expected the supplied logger to be installed verbatim") + } + // Sanity: the installed logger satisfies the SDK interface contract. + if !o.DebugLogger.IsDebugEnabled() { + t.Error("installed logger IsDebugEnabled() returned false") + } + o.DebugLogger.LogDebug("hello") + if len(l.messages) != 1 || l.messages[0] != "hello" { + t.Errorf("LogDebug not forwarded; got %v", l.messages) + } +} + +// TestWithDebugLoggerNilClears verifies that passing a nil logger to +// WithDebugLogger clears any previously-installed logger. This lets later +// options override earlier ones the same way WithModel / WithStreaming do. +func TestWithDebugLoggerNilClears(t *testing.T) { + o := &kit.Options{} + kit.WithDebugLogger(&recordingDebugLogger{enabled: true})(o) + kit.WithDebugLogger(nil)(o) + if o.DebugLogger != nil { + t.Errorf("WithDebugLogger(nil): expected DebugLogger to be cleared; got %#v", o.DebugLogger) + } +} + // TestOptionOrderingOverrides verifies later options override earlier ones. func TestOptionOrderingOverrides(t *testing.T) { o := &kit.Options{}