Skip to content

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

@ezynda3

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions