Skip to content

feat: embedder SDK hardening — deferred structural items (provider injection, LLMUsage.Extra, Kit.Fork) #70

Description

@ezynda3

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.gomodels.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.gokit.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.goKit.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:

  1. LLMUsage.Extra — decide upstream-fantasy vs. kit-side wrapper first
    (breaking-change call), then thread provider population through.
  2. Provider injectionfantasy.LanguageModel adapter + bypass seam in
    agent.NewAgent/SetModel; lets the rest of the test suite drop
    httptest.Server harnesses.
  3. Kit.Fork — refactor New() to share viper/provider/event-bus, then add
    the Fork entry point.

Checklist

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions