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/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..797f6b18 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`. @@ -329,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) @@ -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/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/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/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/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/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)) } } 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{} 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`