Compare commits

..

3 Commits

Author SHA1 Message Date
kit-agent 0bbccbb0a5 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
2026-06-18 14:22:32 +03:00
kit-agent 276c787937 refactor(sdk): drop unreachable kit.AgentConfig surface
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
2026-06-18 13:45:00 +03:00
kit-agent 40bc710938 feat(sdk): add Options.DebugLogger and WithDebugLogger option
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.
2026-06-18 13:38:14 +03:00
12 changed files with 181 additions and 400 deletions
+4 -4
View File
@@ -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
+11 -1
View File
@@ -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
+4 -3
View File
@@ -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
-208
View File
@@ -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 }
+18 -1
View File
@@ -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,
+5 -33
View File
@@ -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.
+11
View File
@@ -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 } }
+4 -108
View File
@@ -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)
+35 -41
View File
@@ -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))
}
}
+46
View File
@@ -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{}
+42 -1
View File
@@ -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
+1
View File
@@ -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`