Feature Description
Follow-up to #68 (embedder SDK hardening). That issue landed 11 of 14
items in #69. The remaining 3 items are deferred here because each is either
a breaking change or an agent-core/architecture shift that warrants its own
design discussion and sign-off — they are not cleanly additive in pkg/kit
alone like the other 11 were.
Original parent: #68 · Implementation PR for the landed items: #69
1. Pluggable Provider injection point (was #68 item #2)
Location: pkg/kit/kit.go — models.CreateProvider is the only path to
construct a model backend, and it always spins up a real HTTP client. There is
no seam for an embedder to inject a synchronous in-process responder.
Cost: every embedder writing tests against kit.Kit stands up an
httptest.Server that speaks the OpenAI/Anthropic protocol they're targeting —
200–400 LOC of harness per project, each test pays a TCP round-trip + JSON
encode/decode, and there's no structural guarantee a KIT_NO_NETWORK=1-style
flag is honoured.
Proposed fix: add a Provider option that bypasses models.CreateProvider.
// pkg/kit/options.go
// ProviderFn is a synchronous in-process replacement for the HTTP-backed
// providers that kit constructs from a model string. When non-nil, kit
// skips models.CreateProvider entirely and routes every turn through fn.
// Provider takes precedence over Model resolution. Intended primarily for
// tests and offline harnesses.
type ProviderFn func(ctx context.Context, req ProviderRequest) (ProviderResponse, error)
type ProviderRequest struct {
Messages []LLMMessage
Tools []Tool
System string
MaxTokens int
Streaming bool
Temperature *float64
}
type ProviderResponse struct {
Messages []LLMMessage // assistant + tool messages in order
StopReason string
Usage *LLMUsage
Deltas []StreamDelta // optional, when Streaming was true
}
type Options struct {
// … existing fields …
// Provider, when non-nil, replaces model-string-driven provider
// construction. The Model field is ignored.
Provider ProviderFn
}
Where models.CreateProvider is called, branch on m.opts.Provider != nil and
wrap it in an adapter that satisfies the same fantasy.LanguageModel interface
the rest of the loop uses.
Why deferred: requires implementing the full fantasy.LanguageModel
interface (Generate/Stream/GenerateObject/StreamObject) as an adapter
and a new bypass seam in agent.NewAgent/agent.SetModel (both unconditionally
call models.CreateProvider). ~200+ LOC touching the agent core; warrants its
own PR.
2. LLMUsage.Extra for provider-specific token buckets (was #68 item #7)
Location: pkg/kit/types.go — kit.LLMUsage = fantasy.Usage (type alias).
// charm.land/fantasy Usage has fixed buckets:
type Usage struct {
InputTokens, OutputTokens, TotalTokens int64
ReasoningTokens, CacheCreationTokens, CacheReadTokens int64
}
When a provider exposes a new bucket (audio, image, web-search tokens, etc.),
embedders mapping kit.LLMUsage into their own typed Usage silently drop it
because the field doesn't exist.
Proposed fix: either add Extra map[string]int64 to fantasy.Usage
upstream, or make kit.LLMUsage a kit-owned struct embedding fantasy.Usage
plus the extra map:
type LLMUsage struct {
fantasy.Usage
// Extra carries provider-specific token buckets keyed with a provider
// prefix (e.g. "openai.audio_tokens") to avoid collisions.
Extra map[string]int64
}
Why deferred: kit.LLMUsage is currently a type alias for the external
fantasy.Usage (v0.32.0). Converting it to a wrapper struct is a breaking
change for any embedder assigning between the two, and fantasy.Usage exposes
no per-provider bucket for kit to populate Extra from today — so the field
would be hollow without an upstream fantasy change first. Needs an explicit
decision: upstream the field, or accept the alias break.
3. Kit.Fork — lightweight sub-task spawning (was #68 item #13)
Location: pkg/kit/kit.go — Kit.Subagent inherits provider config and MCP
task options but still goes through a full New(ctx, childOpts): new config
store, re-validated provider credentials, possibly a re-loaded models DB. For
embedders spawning many short-lived sub-tasks, this dominates per-task cost.
Proposed fix: add a lighter-weight Kit.Fork:
// ForkConfig configures a forked Kit for a short-lived sub-task. Unlike
// Subagent, Fork reuses the parent's provider client, session manager (if
// not overridden), event bus, and MCP connections — only the system prompt
// and tool set are scoped to the fork. The returned Kit must be Close()d;
// closing the fork does not affect the parent.
type ForkConfig struct {
SystemPrompt string
Tools []Tool
ExtraTools []Tool
Model string // empty = inherit from parent
SessionManager SessionManager // nil = share parent's session
}
func (m *Kit) Fork(ctx context.Context, cfg ForkConfig) (*Kit, error)
Internally Fork constructs a *Kit that shares the parent's *viper.Viper,
provider client, and event bus, only re-allocating what the fork-config changes.
Why deferred: needs New() refactored to share the parent's
*viper.Viper / provider client / event bus; doing this correctly (not just a
Subagent-style rebuild) is an architecture change that should be designed
deliberately.
Motivation / Use Case
These three are the structural/breaking subset of the embedder SDK hardening
work in #68. Tackling them separately keeps each design discussion focused:
Proposed Implementation
Suggested order, each as its own PR:
LLMUsage.Extra — decide upstream-fantasy vs. kit-side wrapper first
(breaking-change call), then thread provider population through.
Provider injection — fantasy.LanguageModel adapter + bypass seam in
agent.NewAgent/SetModel; lets the rest of the test suite drop
httptest.Server harnesses.
Kit.Fork — refactor New() to share viper/provider/event-bus, then add
the Fork entry point.
Checklist
Feature Description
Follow-up to #68 (embedder SDK hardening). That issue landed 11 of 14
items in #69. The remaining 3 items are deferred here because each is either
a breaking change or an agent-core/architecture shift that warrants its own
design discussion and sign-off — they are not cleanly additive in
pkg/kitalone like the other 11 were.
Original parent: #68 · Implementation PR for the landed items: #69
1. Pluggable
Providerinjection point (was #68 item #2)Location:
pkg/kit/kit.go—models.CreateProvideris the only path toconstruct a model backend, and it always spins up a real HTTP client. There is
no seam for an embedder to inject a synchronous in-process responder.
Cost: every embedder writing tests against
kit.Kitstands up anhttptest.Serverthat speaks the OpenAI/Anthropic protocol they're targeting —200–400 LOC of harness per project, each test pays a TCP round-trip + JSON
encode/decode, and there's no structural guarantee a
KIT_NO_NETWORK=1-styleflag is honoured.
Proposed fix: add a
Provideroption that bypassesmodels.CreateProvider.Where
models.CreateProvideris called, branch onm.opts.Provider != nilandwrap it in an adapter that satisfies the same
fantasy.LanguageModelinterfacethe rest of the loop uses.
Why deferred: requires implementing the full
fantasy.LanguageModelinterface (
Generate/Stream/GenerateObject/StreamObject) as an adapterand a new bypass seam in
agent.NewAgent/agent.SetModel(both unconditionallycall
models.CreateProvider). ~200+ LOC touching the agent core; warrants itsown PR.
2.
LLMUsage.Extrafor provider-specific token buckets (was #68 item #7)Location:
pkg/kit/types.go—kit.LLMUsage = fantasy.Usage(type alias).When a provider exposes a new bucket (audio, image, web-search tokens, etc.),
embedders mapping
kit.LLMUsageinto their own typedUsagesilently drop itbecause the field doesn't exist.
Proposed fix: either add
Extra map[string]int64tofantasy.Usageupstream, or make
kit.LLMUsagea kit-owned struct embeddingfantasy.Usageplus the extra map:
Why deferred:
kit.LLMUsageis currently a type alias for the externalfantasy.Usage(v0.32.0). Converting it to a wrapper struct is a breakingchange for any embedder assigning between the two, and
fantasy.Usageexposesno per-provider bucket for kit to populate
Extrafrom today — so the fieldwould be hollow without an upstream
fantasychange first. Needs an explicitdecision: upstream the field, or accept the alias break.
3.
Kit.Fork— lightweight sub-task spawning (was #68 item #13)Location:
pkg/kit/kit.go—Kit.Subagentinherits provider config and MCPtask options but still goes through a full
New(ctx, childOpts): new configstore, re-validated provider credentials, possibly a re-loaded models DB. For
embedders spawning many short-lived sub-tasks, this dominates per-task cost.
Proposed fix: add a lighter-weight
Kit.Fork:Internally
Forkconstructs a*Kitthat shares the parent's*viper.Viper,provider client, and event bus, only re-allocating what the fork-config changes.
Why deferred: needs
New()refactored to share the parent's*viper.Viper/ provider client / event bus; doing this correctly (not just aSubagent-style rebuild) is an architecture change that should be designeddeliberately.
Motivation / Use Case
These three are the structural/breaking subset of the embedder SDK hardening
work in #68. Tackling them separately keeps each design discussion focused:
kit.New()cost for high-fan-out sub-task workloads.Proposed Implementation
Suggested order, each as its own PR:
LLMUsage.Extra— decide upstream-fantasyvs. kit-side wrapper first(breaking-change call), then thread provider population through.
Providerinjection —fantasy.LanguageModeladapter + bypass seam inagent.NewAgent/SetModel; lets the rest of the test suite drophttptest.Serverharnesses.Kit.Fork— refactorNew()to share viper/provider/event-bus, then addthe
Forkentry point.Checklist