From 40bc710938d054663ed5f4fa783f5a108fb492f1 Mon Sep 17 00:00:00 2001 From: kit-agent Date: Thu, 18 Jun 2026 13:32:59 +0300 Subject: [PATCH 1/3] 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{} From 276c7879370cdb11c55aea5345610ac73baa1c4a Mon Sep 17 00:00:00 2001 From: kit-agent Date: Thu, 18 Jun 2026 13:42:45 +0300 Subject: [PATCH 2/3] refactor(sdk): drop unreachable kit.AgentConfig surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kit.AgentConfig (pkg/kit/types.go) and its toInternal converter were exposed as the documented "low-level / advanced consumer" path for agent construction, but the converter was unexported and not wired into any public constructor — neither New(*Options) nor NewAgent(...Option) accept an AgentConfig. The only call sites were the dedicated agent_config_internal_test.go (same-package internal test) and two fantasy-import regression tests in types_test.go. Net effect today: no SDK consumer outside pkg/kit can populate or use kit.AgentConfig in any way. The type, the converter, the dedicated test file, and a chain of godoc cross-references all exist purely for their own sake — they don't enlarge what consumers can do, but they do enlarge the SDK's stability contract (every field becomes a public shape the internal agent layer can't refactor freely). The companion PR added Options.DebugLogger + WithDebugLogger so the last functional capability AgentConfig was documented to enable — installing a custom debug logger — is reachable through the supported construction path. With that wired, AgentConfig has no remaining purpose. Changes: - pkg/kit/types.go: remove the AgentConfig struct and its toInternal() method. Drop the now-unused internal/agent and internal/tools imports. Update the DebugLogger godoc to point at Options.DebugLogger and WithDebugLogger instead of AgentConfig. - pkg/kit/agent_config_internal_test.go: deleted (208 LOC). It exercised the unexported toInternal() method directly; with the method gone the test has no subject. - pkg/kit/types_test.go: rename TestAgentConfigNoFantasyImport to TestOptionsNoFantasyImport and rewrite it against Options (SystemPrompt, MaxSteps, Streaming, Tools, ExtraTools, DisableCoreTools, OnMCPServerLoaded). The original test also asserted ToolWrapper field semantics; that capability migrates to the hook system (OnBeforeToolCall / OnAfterToolResult), already covered by hooks_test.go, so the assertion is dropped with a pointer in the godoc. TestAgentConfigToolWrapperSignature replaced by TestToolSliceSignature, which still pins that []kit.Tool is the user-visible slice type for every tool-related SDK surface — the no-fantasy-import contract the original test guarded. - pkg/kit/mcp_tasks.go: update the MCPTaskConfig godoc to stop referencing AgentConfig. MCPTaskConfig stays — it is still emitted through Options.MCPTask* fields and used as the engine-facing config type. - pkg/kit/README.md: drop the kit.AgentConfig line from the type inventory. internal/agent.AgentConfig is untouched and remains the internal construction shape. With the public type gone the internal one can evolve freely without breaking the SDK contract. Verification: - go build ./pkg/... ./internal/... ./cmd/... — clean - go vet ./pkg/... ./internal/... ./cmd/... — clean - go test -race -timeout 300s ./... — all packages pass --- pkg/kit/README.md | 1 - pkg/kit/agent_config_internal_test.go | 208 -------------------------- pkg/kit/mcp_tasks.go | 38 +---- pkg/kit/types.go | 112 +------------- pkg/kit/types_test.go | 76 +++++----- 5 files changed, 44 insertions(+), 391 deletions(-) delete mode 100644 pkg/kit/agent_config_internal_test.go diff --git a/pkg/kit/README.md b/pkg/kit/README.md index 836e66de..917fea17 100644 --- a/pkg/kit/README.md +++ b/pkg/kit/README.md @@ -330,7 +330,6 @@ kit.LLMFilePart // {Filename, Data []byte, MediaType} // Agent configuration — concrete Kit-owned structs and function types. // All fields use SDK types (e.g. `[]kit.Tool`), so consumers can construct // these without importing any LLM-provider package. -kit.AgentConfig // Lower-level agent config — prefer Options unless you need direct control kit.DebugLogger // Interface: LogDebug(string) / IsDebugEnabled() bool kit.MCPTaskConfig // Task-aware MCP tools/call config (modes, polling, progress) kit.ToolCallHandler // func(toolCallID, toolName, toolArgs string) diff --git a/pkg/kit/agent_config_internal_test.go b/pkg/kit/agent_config_internal_test.go deleted file mode 100644 index dc9a3e4d..00000000 --- a/pkg/kit/agent_config_internal_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package kit - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/mark3labs/kit/internal/agent" -) - -// TestAgentConfigToInternal verifies that the SDK-side AgentConfig converts -// faithfully to the internal agent.AgentConfig representation, preserving -// every field consumed by the internal agent layer. -// -// Regression test for https://github.com/mark3labs/kit/issues/30. -func TestAgentConfigToInternal(t *testing.T) { - t.Run("nil receiver returns nil", func(t *testing.T) { - var c *AgentConfig - if got := c.toInternal(); got != nil { - t.Errorf("nil.toInternal() = %v, want nil", got) - } - }) - - t.Run("scalar fields round-trip", func(t *testing.T) { - c := &AgentConfig{ - SystemPrompt: "sys", - MaxSteps: 7, - StreamingEnabled: true, - DisableCoreTools: true, - } - got := c.toInternal() - if got == nil { - t.Fatal("toInternal() = nil") - } - if got.SystemPrompt != "sys" { - t.Errorf("SystemPrompt = %q, want %q", got.SystemPrompt, "sys") - } - if got.MaxSteps != 7 { - t.Errorf("MaxSteps = %d, want 7", got.MaxSteps) - } - if !got.StreamingEnabled { - t.Error("StreamingEnabled = false, want true") - } - if !got.DisableCoreTools { - t.Error("DisableCoreTools = false, want true") - } - }) - - t.Run("tool slices propagate without conversion", func(t *testing.T) { - // Tool is a type alias for the underlying LLM-tool type, so the - // SDK []Tool and internal []fantasy.AgentTool slices share the - // same backing array after conversion. - tool := NewTool[struct{}]("noop", "noop", nil) - c := &AgentConfig{ - CoreTools: []Tool{tool}, - ExtraTools: []Tool{tool, tool}, - } - got := c.toInternal() - if len(got.CoreTools) != 1 { - t.Errorf("CoreTools len = %d, want 1", len(got.CoreTools)) - } - if len(got.ExtraTools) != 2 { - t.Errorf("ExtraTools len = %d, want 2", len(got.ExtraTools)) - } - }) - - t.Run("tool wrapper is invoked through internal config", func(t *testing.T) { - called := false - c := &AgentConfig{ - ToolWrapper: func(in []Tool) []Tool { - called = true - return in - }, - } - got := c.toInternal() - if got.ToolWrapper == nil { - t.Fatal("internal ToolWrapper is nil") - } - _ = got.ToolWrapper(nil) - if !called { - t.Error("SDK ToolWrapper was not invoked through the internal config") - } - }) - - t.Run("OnMCPServerLoaded propagates", func(t *testing.T) { - var captured string - wantErr := errors.New("boom") - c := &AgentConfig{ - OnMCPServerLoaded: func(name string, _ int, _ error) { - captured = name - }, - } - got := c.toInternal() - got.OnMCPServerLoaded("svr", 3, wantErr) - if captured != "svr" { - t.Errorf("OnMCPServerLoaded captured = %q, want %q", captured, "svr") - } - }) - - t.Run("DebugLogger propagates", func(t *testing.T) { - dl := &fakeDebugLogger{enabled: true} - c := &AgentConfig{DebugLogger: dl} - got := c.toInternal() - if got.DebugLogger == nil { - t.Fatal("internal DebugLogger is nil") - } - if !got.DebugLogger.IsDebugEnabled() { - t.Error("IsDebugEnabled = false, want true") - } - got.DebugLogger.LogDebug("hello") - if len(dl.messages) != 1 || dl.messages[0] != "hello" { - t.Errorf("messages = %v, want [hello]", dl.messages) - } - }) - - t.Run("MCPTaskConfig propagates with mode + progress", func(t *testing.T) { - c := &AgentConfig{ - MCPTaskConfig: MCPTaskConfig{ - PerServerMode: map[string]MCPTaskMode{ - "build-svr": MCPTaskModeAlways, - }, - DefaultTTL: 30 * time.Second, - PollInterval: 250 * time.Millisecond, - MaxPollInterval: 2 * time.Second, - Timeout: 5 * time.Minute, - Progress: func(_ MCPTaskProgress) {}, - }, - } - got := c.toInternal() - if got.MCPTaskConfig.DefaultTTL != 30*time.Second { - t.Errorf("DefaultTTL = %v, want 30s", got.MCPTaskConfig.DefaultTTL) - } - if got.MCPTaskConfig.PollInterval != 250*time.Millisecond { - t.Errorf("PollInterval = %v, want 250ms", got.MCPTaskConfig.PollInterval) - } - if got.MCPTaskConfig.MaxPollInterval != 2*time.Second { - t.Errorf("MaxPollInterval = %v, want 2s", got.MCPTaskConfig.MaxPollInterval) - } - if got.MCPTaskConfig.Timeout != 5*time.Minute { - t.Errorf("Timeout = %v, want 5m", got.MCPTaskConfig.Timeout) - } - mode, ok := got.MCPTaskConfig.PerServerMode["build-svr"] - if !ok { - t.Fatal("PerServerMode missing 'build-svr'") - } - if string(mode) != string(MCPTaskModeAlways) { - t.Errorf("mode = %q, want %q", mode, MCPTaskModeAlways) - } - if got.MCPTaskConfig.Progress == nil { - t.Fatal("internal Progress handler is nil") - } - }) - - t.Run("auth and token store factories are wired", func(t *testing.T) { - auth := &fakeAuthHandler{} - tokenCalls := 0 - var tokenServer string - factory := MCPTokenStoreFactory(func(server string) (MCPTokenStore, error) { - tokenCalls++ - tokenServer = server - return nil, nil - }) - c := &AgentConfig{ - AuthHandler: auth, - TokenStoreFactory: factory, - } - got := c.toInternal() - if got.AuthHandler == nil { - t.Fatal("internal AuthHandler is nil") - } - if got.TokenStoreFactory == nil { - t.Fatal("internal TokenStoreFactory is nil") - } - _, _ = got.TokenStoreFactory("https://example.test") - if tokenCalls != 1 { - t.Errorf("token factory call count = %d, want 1", tokenCalls) - } - if tokenServer != "https://example.test" { - t.Errorf("token factory server arg = %q", tokenServer) - } - if got.AuthHandler.RedirectURI() != "redirect" { - t.Errorf("RedirectURI = %q, want %q", got.AuthHandler.RedirectURI(), "redirect") - } - }) - - // Compile-time check that the internal type is what we expect. - //nolint:staticcheck // QF1011: explicit type asserts the conversion target. - var _ *agent.AgentConfig = (&AgentConfig{}).toInternal() -} - -// fakeAuthHandler implements both kit.MCPAuthHandler and the structurally -// identical tools.MCPAuthHandler used by the internal layer. -type fakeAuthHandler struct{} - -func (f *fakeAuthHandler) RedirectURI() string { return "redirect" } -func (f *fakeAuthHandler) HandleAuth(_ context.Context, _ string, _ string) (string, error) { - return "", nil -} - -// fakeDebugLogger implements kit.DebugLogger for tests. -type fakeDebugLogger struct { - enabled bool - messages []string -} - -func (f *fakeDebugLogger) LogDebug(m string) { f.messages = append(f.messages, m) } -func (f *fakeDebugLogger) IsDebugEnabled() bool { return f.enabled } diff --git a/pkg/kit/mcp_tasks.go b/pkg/kit/mcp_tasks.go index f46e9303..cce40fe5 100644 --- a/pkg/kit/mcp_tasks.go +++ b/pkg/kit/mcp_tasks.go @@ -102,10 +102,11 @@ type MCPTaskProgressHandler func(MCPTaskProgress) // are optional; the zero value disables progress callbacks and applies // sensible polling defaults inside the engine. // -// For most consumers, the flat [Options] fields (`MCPTaskMode`, -// `MCPTaskTTL`, `MCPTaskPollInterval`, `MCPTaskMaxPollInterval`, -// `MCPTaskTimeout`, `MCPTaskProgress`) are the preferred entry point. -// MCPTaskConfig is exposed for the low-level [AgentConfig] path. +// Most consumers configure these via the flat [Options] fields +// (`MCPTaskMode`, `MCPTaskTTL`, `MCPTaskPollInterval`, +// `MCPTaskMaxPollInterval`, `MCPTaskTimeout`, `MCPTaskProgress`). The +// MCPTaskConfig type itself is retained for downstream consumers that +// receive it on engine-facing call sites. type MCPTaskConfig struct { // PerServerMode overrides the per-server task mode resolved from // [MCPServerConfig]. Keys are server names. Missing entries fall back @@ -133,35 +134,6 @@ type MCPTaskConfig struct { Progress MCPTaskProgressHandler } -// toToolsConfig converts the SDK-level [MCPTaskConfig] to the internal -// tools-package representation. Keeps the dependency arrow internal-only. -func (c MCPTaskConfig) toToolsConfig() tools.MCPTaskConfig { - cfg := tools.MCPTaskConfig{ - DefaultTTL: c.DefaultTTL, - PollInterval: c.PollInterval, - MaxPollInterval: c.MaxPollInterval, - Timeout: c.Timeout, - } - if len(c.PerServerMode) > 0 { - cfg.PerServerMode = make(map[string]tools.MCPTaskMode, len(c.PerServerMode)) - for k, v := range c.PerServerMode { - cfg.PerServerMode[k] = tools.MCPTaskMode(v) - } - } - if c.Progress != nil { - h := c.Progress - cfg.Progress = func(p tools.MCPTaskProgress) { - h(MCPTaskProgress{ - Server: p.Server, - TaskID: p.TaskID, - Status: MCPTaskStatus(p.Status), - Message: p.Message, - }) - } - } - return cfg -} - // mcpTaskOptions carries SDK consumer configuration into the agent setup. // Stored on Options as a single value so the public surface stays compact; // individual fields are exposed via WithMCP* builder functions. diff --git a/pkg/kit/types.go b/pkg/kit/types.go index 5f2428a5..9ff7c7cd 100644 --- a/pkg/kit/types.go +++ b/pkg/kit/types.go @@ -5,13 +5,11 @@ import ( "charm.land/fantasy" - "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/compaction" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/message" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/session" - "github.com/mark3labs/kit/internal/tools" "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/server" ) @@ -83,9 +81,10 @@ type MCPServerConfig = config.MCPServerConfig // concurrent use. // // Most consumers do not need to provide one; pass [Options.Debug] = true -// to use the default logger. DebugLogger is exposed for the low-level -// [AgentConfig] path and for embedders that want to route debug output -// into their own logging system. +// (or use [WithDebug]) to install the built-in console logger. DebugLogger +// is the escape hatch for embedders that want to route debug output into +// their own logging system — install one via [Options.DebugLogger] or +// [WithDebugLogger]. type DebugLogger interface { // LogDebug records a single debug message. Implementations may drop, // buffer, or render the message however they choose. @@ -95,109 +94,6 @@ type DebugLogger interface { IsDebugEnabled() bool } -// AgentConfig holds configuration options for constructing an agent at the -// SDK boundary. All fields use SDK-owned types, so consumers can populate -// this struct without importing any underlying LLM-provider package. -// -// For most use cases, prefer the high-level [New] entry point with -// [Options]. AgentConfig is exposed for advanced consumers that need -// direct access to the lower-level agent configuration shape. -type AgentConfig struct { - // ModelConfig holds the LLM provider configuration. A nil value means - // that the default provider/model resolution will be used. - ModelConfig *ProviderConfig - - // MCPConfig describes any MCP servers whose tools should be loaded - // alongside core tools. - MCPConfig *Config - - // SystemPrompt is the system prompt sent to the LLM. - SystemPrompt string - - // MaxSteps caps the number of LLM iterations per turn. A value of - // zero means no cap is applied at this layer. - MaxSteps int - - // StreamingEnabled controls whether the agent streams responses. - StreamingEnabled bool - - // AuthHandler handles OAuth authorization for remote MCP servers. - // When nil, remote MCP servers requiring OAuth will fail to connect. - AuthHandler MCPAuthHandler - - // TokenStoreFactory, if non-nil, creates a custom token store for each - // remote MCP server's OAuth tokens. When nil, the default file-based - // token store is used. - TokenStoreFactory MCPTokenStoreFactory - - // CoreTools overrides the default core tool set. If empty, [AllTools] - // is used. Provide a custom tool set (e.g. [CodingTools] or tools - // built with a custom WorkDir) to scope agent capabilities. - CoreTools []Tool - - // DisableCoreTools, when true, prevents loading any core tools. - // Combined with empty CoreTools this yields a chat-only agent with - // no built-in tools. - DisableCoreTools bool - - // ExtraTools are additional tools loaded alongside core and MCP tools. - ExtraTools []Tool - - // ToolWrapper, if non-nil, wraps the combined tool list before it is - // handed to the LLM. Used to intercept tool calls or results. - ToolWrapper func([]Tool) []Tool - - // OnMCPServerLoaded, if non-nil, is invoked once for each MCP server - // when its tools have finished loading (or failed). Called from a - // background goroutine. - OnMCPServerLoaded func(serverName string, toolCount int, err error) - - // DebugLogger receives low-level debug output from the engine and the - // MCP tool plumbing. Nil means no debug output is emitted at this - // layer (regardless of [Options.Debug], which feeds the higher-level - // [New] entry point). Pass an implementation here when wiring a custom - // logger through the lower-level AgentConfig path. - DebugLogger DebugLogger - - // MCPTaskConfig configures task-aware MCP tools/call execution — mode - // overrides, polling intervals, timeouts, and the progress handler. - // The zero value preserves historical synchronous-only behaviour for - // any server that didn't advertise task support during initialize. - MCPTaskConfig MCPTaskConfig -} - -// toInternal converts an AgentConfig to its internal representation. -// Slice and function fields convert without allocation because [Tool] -// is a type alias for the underlying LLM-tool type. -func (c *AgentConfig) toInternal() *agent.AgentConfig { - if c == nil { - return nil - } - out := &agent.AgentConfig{ - ModelConfig: c.ModelConfig, - MCPConfig: c.MCPConfig, - SystemPrompt: c.SystemPrompt, - MaxSteps: c.MaxSteps, - StreamingEnabled: c.StreamingEnabled, - CoreTools: c.CoreTools, - DisableCoreTools: c.DisableCoreTools, - ExtraTools: c.ExtraTools, - ToolWrapper: c.ToolWrapper, - OnMCPServerLoaded: c.OnMCPServerLoaded, - } - if c.AuthHandler != nil { - out.AuthHandler = c.AuthHandler - } - if c.TokenStoreFactory != nil { - out.TokenStoreFactory = tools.TokenStoreFactory(c.TokenStoreFactory) - } - if c.DebugLogger != nil { - out.DebugLogger = c.DebugLogger - } - out.MCPTaskConfig = c.MCPTaskConfig.toToolsConfig() - return out -} - // ToolCallHandler is invoked when the LLM produces a tool call. It receives // the call ID, tool name, and the JSON-encoded input arguments. type ToolCallHandler func(toolCallID, toolName, toolArgs string) diff --git a/pkg/kit/types_test.go b/pkg/kit/types_test.go index 74a6b7fb..896c1c22 100644 --- a/pkg/kit/types_test.go +++ b/pkg/kit/types_test.go @@ -264,30 +264,31 @@ func TestConvertFromLLMMessage(t *testing.T) { } } -// TestAgentConfigNoFantasyImport verifies AgentConfig can be populated with -// every field — including CoreTools, ExtraTools, and ToolWrapper — using -// only SDK-owned types. This test deliberately does not import -// "charm.land/fantasy"; the package compiling at all is the proof that the -// SDK no longer leaks the dependency name through AgentConfig. +// TestOptionsNoFantasyImport verifies Options can be populated with the +// tool-related fields — Tools and ExtraTools — using only SDK-owned types. +// This test deliberately does not import "charm.land/fantasy"; the package +// compiling at all is the proof that the SDK no longer leaks the dependency +// name through the Options surface. +// +// Tool-call interception (formerly the AgentConfig.ToolWrapper escape hatch) +// is covered by the hook system — [Kit.OnBeforeToolCall] / +// [Kit.OnAfterToolResult] — whose hook payload types also use only +// SDK-owned identifiers; see hooks_test.go. // // Regression test for https://github.com/mark3labs/kit/issues/30. -func TestAgentConfigNoFantasyImport(t *testing.T) { +func TestOptionsNoFantasyImport(t *testing.T) { myTool := kit.NewTool[struct{}]("noop", "does nothing", func(_ context.Context, _ struct{}) (kit.ToolOutput, error) { return kit.TextResult("ok"), nil }) - wrapperCalled := false - cfg := kit.AgentConfig{ - SystemPrompt: "you are a tester", - MaxSteps: 5, - StreamingEnabled: true, - CoreTools: []kit.Tool{myTool}, - ExtraTools: []kit.Tool{myTool}, - DisableCoreTools: false, - ToolWrapper: func(in []kit.Tool) []kit.Tool { - wrapperCalled = true - return in - }, + streaming := true + cfg := kit.Options{ + SystemPrompt: "you are a tester", + MaxSteps: 5, + Streaming: &streaming, + Tools: []kit.Tool{myTool}, + ExtraTools: []kit.Tool{myTool}, + DisableCoreTools: false, OnMCPServerLoaded: func(_ string, _ int, _ error) {}, } @@ -297,36 +298,29 @@ func TestAgentConfigNoFantasyImport(t *testing.T) { if cfg.MaxSteps != 5 { t.Errorf("MaxSteps = %d, want 5", cfg.MaxSteps) } - if !cfg.StreamingEnabled { - t.Error("StreamingEnabled = false, want true") + if cfg.Streaming == nil || !*cfg.Streaming { + t.Error("Streaming = false/nil, want true") } - if len(cfg.CoreTools) != 1 { - t.Errorf("CoreTools len = %d, want 1", len(cfg.CoreTools)) + if len(cfg.Tools) != 1 { + t.Errorf("Tools len = %d, want 1", len(cfg.Tools)) } if len(cfg.ExtraTools) != 1 { t.Errorf("ExtraTools len = %d, want 1", len(cfg.ExtraTools)) } - - // Exercise the wrapper to confirm the func type is usable. - out := cfg.ToolWrapper(cfg.CoreTools) - if !wrapperCalled { - t.Error("ToolWrapper was not invoked") - } - if len(out) != 1 { - t.Errorf("wrapped tool list len = %d, want 1", len(out)) - } } -// TestAgentConfigToolWrapperSignature documents that AgentConfig.ToolWrapper -// uses kit.Tool (not the underlying provider type) in its signature. -func TestAgentConfigToolWrapperSignature(t *testing.T) { - //nolint:staticcheck // QF1011: explicit type asserts the SDK-side func signature. - var _ func([]kit.Tool) []kit.Tool = func(in []kit.Tool) []kit.Tool { return in } - cfg := kit.AgentConfig{ - ToolWrapper: func(in []kit.Tool) []kit.Tool { return in }, - } - if cfg.ToolWrapper == nil { - t.Fatal("ToolWrapper assignment failed") +// TestToolSliceSignature documents that the kit.Tool alias — used by every +// SDK tool-related surface (Options.Tools, Options.ExtraTools, WithTools, +// WithExtraTools, hook payloads) — is referenced under its SDK-owned name +// in user code, without any fantasy import. +func TestToolSliceSignature(t *testing.T) { + var tools []kit.Tool + tools = append(tools, kit.NewTool[struct{}]("noop", "", + func(_ context.Context, _ struct{}) (kit.ToolOutput, error) { + return kit.TextResult("ok"), nil + })) + if len(tools) != 1 { + t.Fatalf("unexpected tool slice length: %d", len(tools)) } } From 0bbccbb0a595746c2abc85e6306bab22f6e7c48b Mon Sep 17 00:00:00 2001 From: kit-agent Date: Thu, 18 Jun 2026 14:22:32 +0300 Subject: [PATCH 3/3] docs(sdk): document Options.DebugLogger and WithDebugLogger - README.md: add WithDebugLogger to the functional-options helper list - pkg/kit/README.md: expand the Debug row and add a DebugLogger row in the Options field summary - www/pages/sdk/overview.md: add WithDebugLogger to the helpers table with a note that it overrides WithDebug when set - www/pages/sdk/options.md: surface DebugLogger in the example, expand the Debug field description, add a DebugLogger row to the Core fields table, and add a "Custom debug logger" section with the interface signature and a log/slog adapter example --- README.md | 8 ++++---- pkg/kit/README.md | 3 ++- www/pages/sdk/options.md | 43 ++++++++++++++++++++++++++++++++++++++- www/pages/sdk/overview.md | 1 + 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 91f98d81..9cb829cb 100644 --- a/README.md +++ b/README.md @@ -691,10 +691,10 @@ host, err := kit.NewAgent(ctx, Available options: `WithModel`, `WithSystemPrompt`, `WithStreaming`, `WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`, -`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, and -`Ephemeral`. For advanced configuration not covered by the helpers (custom MCP -config, in-process MCP servers, session backends, MCP task tuning) construct an -`Options` value explicitly and call `kit.New`. +`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, +`WithDebugLogger`, and `Ephemeral`. For advanced configuration not covered by +the helpers (custom MCP config, in-process MCP servers, session backends, MCP +task tuning) construct an `Options` value explicitly and call `kit.New`. ### Per-instance config isolation diff --git a/pkg/kit/README.md b/pkg/kit/README.md index 917fea17..797f6b18 100644 --- a/pkg/kit/README.md +++ b/pkg/kit/README.md @@ -403,7 +403,8 @@ Key `Options` fields for SDK usage: | `SessionPath` | Open specific session file | | `Continue` | Resume most recent session | | `InProcessMCPServers` | Map of name → `*kit.MCPServer` for in-process MCP servers | -| `Debug` | Enable debug logging | +| `Debug` | Enable debug logging via the built-in console logger (ignored when `DebugLogger` is set) | +| `DebugLogger` | Custom `DebugLogger` implementation — routes engine + MCP debug output into your own logging system | ## Environment Variables diff --git a/www/pages/sdk/options.md b/www/pages/sdk/options.md index 424c1ce4..931d2b7a 100644 --- a/www/pages/sdk/options.md +++ b/www/pages/sdk/options.md @@ -31,6 +31,7 @@ host, err := kit.New(ctx, &kit.Options{ Streaming: ptrBool(true), // *bool: nil = unset (default true), &false = off Quiet: true, Debug: true, + DebugLogger: myLogger, // optional; overrides Debug + built-in logger when non-nil // Generation parameters (override env/config/per-model defaults) MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing @@ -103,7 +104,8 @@ host, err := kit.New(ctx, &kit.Options{ | `MaxSteps` | `int` | `0` | Max agent steps (0 = unlimited) | | `Streaming` | `*bool` | `nil` | Enable streaming output. `nil` leaves it to the precedence chain (env → config → default `true`); `&true`/`&false` forces it. Pointer so unset is distinct from explicit `false`. | | `Quiet` | `bool` | `false` | Suppress output | -| `Debug` | `bool` | `false` | Enable debug logging | +| `Debug` | `bool` | `false` | Enable debug logging via the built-in console / buffered logger. Ignored when `DebugLogger` is non-nil. | +| `DebugLogger` | `DebugLogger` | `nil` | Caller-supplied logger that receives low-level engine + MCP tool plumbing debug output. When non-nil this overrides `Debug` — the supplied logger's `IsDebugEnabled()` controls downstream emission. See [Custom debug logger](#custom-debug-logger). | ### Generation parameters @@ -346,6 +348,45 @@ loaded MCP server that advertises the corresponding capability. Context cancellation also works end-to-end: cancelling the `ctx` passed to a tool execution triggers a best-effort `tasks/cancel` before the call returns. +## Custom debug logger + +Kit's engine and MCP tool plumbing emit low-level debug output through a +`DebugLogger` interface. By default, setting `Debug: true` (or calling +`WithDebug()`) installs the built-in console logger. To route the same output +into your application's logging system instead, provide a custom +implementation via `Options.DebugLogger` or `WithDebugLogger`. + +```go +type DebugLogger interface { + LogDebug(message string) + IsDebugEnabled() bool +} +``` + +When `DebugLogger` is non-nil it takes precedence over `Debug` — the +supplied logger's `IsDebugEnabled()` reports whether downstream code should +bother formatting messages. + +**Example: forward to `log/slog`:** + +```go +import "log/slog" + +type slogDebugLogger struct{ l *slog.Logger } + +func (s *slogDebugLogger) LogDebug(m string) { s.l.Debug(m) } +func (s *slogDebugLogger) IsDebugEnabled() bool { return true } + +host, _ := kit.NewAgent(ctx, + kit.WithModel("anthropic/claude-sonnet-4-5-20250929"), + kit.WithDebugLogger(&slogDebugLogger{l: slog.Default()}), +) +``` + +Implementations must be safe for concurrent use — messages can arrive +from the engine goroutine, MCP connection pool, and tool execution paths +simultaneously. + ## Precedence For any given generation or provider field, the effective value is resolved diff --git a/www/pages/sdk/overview.md b/www/pages/sdk/overview.md index 9214b4d2..bccfc353 100644 --- a/www/pages/sdk/overview.md +++ b/www/pages/sdk/overview.md @@ -80,6 +80,7 @@ Available options: | `WithProviderURL(string)` | `Options.ProviderURL` | | `WithConfigFile(string)` | `Options.ConfigFile` | | `WithDebug()` | `Options.Debug = true` | +| `WithDebugLogger(DebugLogger)` | `Options.DebugLogger` (route engine + MCP debug output into a custom logger; overrides `WithDebug` when set) | | `Ephemeral()` | `Options.NoSession = true` | Options are applied in order, so later options override earlier ones. `Option`