Compare commits

..

114 Commits

Author SHA1 Message Date
Sai Karthik 7f366eab84 cmd: add --no-skills, --skill, and --skills-dir CLI flags & config (#55)
* cmd: add --no-skills, --skill, and --skills-dir CLI flags

The pkg/kit Options struct already had full backend support for skills
control (NoSkills, Skills []string, SkillsDir) wired into loadSkills()
in pkg/kit/kit.go, but there were no corresponding CLI flags to drive
them. This commit closes that gap.

Changes in cmd/root.go:

- Add three package-level flag variables alongside the existing
  noExtensionsFlag/extensionPaths group:
    noSkillsFlag bool
    skillsPaths  []string
    skillsDir    string

- Register three persistent cobra flags in init():
    --no-skills        disable skill loading (auto-discovery and explicit)
    --skill <path>     load a skill file or directory (repeatable)
    --skills-dir <dir> override the project-local skills directory
                       used for auto-discovery

- Wire all three into the kitOpts struct literal in runNormalMode()
  so they flow directly into kit.New() -> loadSkills().

No changes to pkg/kit or internal/skills -- the backend was already
complete. No viper binding is needed because kit.go reads these fields
directly from opts rather than from viper (unlike NoExtensions which
uses the viper fallback path).

Example usage:
  kit --no-skills "prompt"
  kit --skill ./my-skill.md --skill ./other-skill.md "prompt"
  kit --skills-dir /path/to/skills "prompt"

Co-authored-by: Claude <claude@anthropic.com>

* docs: document --no-skills, --skill, and --skills-dir CLI flags

Add the three new skills CLI flags to all relevant documentation:

- README.md: add Skills section under Global Flags CLI reference
- www/pages/cli/flags.md: add Skills table (mirrors Extensions section pattern)
- www/pages/cli/commands.md: expand the Skills section with usage examples
  and a description of auto-discovery vs explicit loading vs --no-skills

Co-authored-by: Claude <claude@anthropic.com>

* feat: add config file support for skills options

Skills could previously only be controlled via CLI flags or SDK Options
fields. This commit wires all three skills settings into viper so they
can also be set in .kit.yml / .kit.yaml / .kit.json and via KIT_*
environment variables — matching the pattern used by no-extensions,
no-core-tools, and prompt-template.

cmd/root.go:
- Bind --no-skills, --skill, and --skills-dir flags to viper keys
  (no-skills, skill, skills-dir) so config file values flow through.

pkg/kit/kit.go:
- At skill-load time, merge opts fields with viper values:
  - noSkills = opts.NoSkills || v.GetBool("no-skills")
  - skillPaths: opts.Skills if non-empty, else v.GetStringSlice("skill")
  - skillsDir: opts.SkillsDir if non-empty, else v.GetString("skills-dir")
- Build a shallow-copied mergedOpts so loadSkills() picks up the
  resolved values without mutating the original Options struct.

docs:
- README.md: add skills keys to the Basic Configuration YAML example
- www/pages/configuration.md: add no-skills, skill, skills-dir rows to
  the All configuration keys table

Config file example (.kit.yml):
  no-skills: false
  skill:
    - /path/to/skill.md
  skills-dir: /path/to/skills/

Co-authored-by: Claude <claude@anthropic.com>

* config: add skills keys to default .kit.yml template

Add no-skills, skill, and skills-dir as commented-out examples in the
default config file generated by EnsureConfigExists(), alongside the
existing application settings block.

Co-authored-by: Claude <claude@anthropic.com>

* test: add test coverage for skills CLI flags and config keys

Four test locations updated:

pkg/kit/export_test.go:
- Add ConfigStringSliceForTest() helper to expose v.GetStringSlice()
  from the Kit's isolated viper store, needed to assert skill list values.

pkg/kit/kit_test.go (TestNewWithSkillsOptions):
- NoSkills=true: GetSkills() returns empty slice
- SkillsDir=<empty dir>: kit.New() succeeds with zero skills
- Skills=[file]: single explicit skill file is loaded and name parsed correctly

pkg/kit/viper_isolation_test.go:
- TestSkillsViperKeys: no-API-key struct-level checks for NoSkills, Skills,
  and SkillsDir fields on Options
- TestSkillsConfigFileKeys: full kit.New() round-trips via a written .kit.yml
  for each of the three config keys:
    no-skills: true  → GetSkills() returns empty
    skill: [path]    → named skill loaded from config file path
    skills-dir: dir  → custom discovery root accepted without error

internal/config/config_test.go (TestEnsureConfigExists):
- Assert generated ~/.kit.yml template contains '# Skills configuration',
  'no-skills:', and 'skills-dir:' comment blocks.

Co-authored-by: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-06-12 16:23:17 +03:00
Ed Zynda e8e99b19a8 refactor: dedupe cross-package logic and remove dead code from audit (#58)
* Remove dead code: 5 unused symbols across internal packages

- internal/models: LoadModelSettingsFromConfig (zero refs)
- internal/prompts: PromptTemplate.ExpandWithArgs (zero refs)
- internal/app: NewMessageStore (tests migrated to NewMessageStoreWithMessages)
- internal/config: HasEnvVars (+ its test)
- internal/core: ContextWithSudoPassword (test migrated to context.WithValue)

* pkg/kit: use TreeManager alias in exported signatures

NewTreeManagerAdapter and InitTreeSession now spell their signatures with
the public kit.TreeManager alias instead of internal/session.TreeManager,
so go doc renders domain types rather than internal paths.

* Consolidate tool-kind classification into internal/extensions

coreToolKinds + toolKindFor were duplicated verbatim in
internal/extensions/wrapper.go and pkg/kit/events.go, risking silent
divergence between extension events and SDK events. Single source of
truth now lives in internal/extensions/toolkinds.go; pkg/kit re-exports
the constants.

* Consolidate Anthropic OAuth detection and usage-tracker refresh

The 'is the active Anthropic credential a stored OAuth token' check was
copy-pasted at 5 sites, all prefix-matching the magic string
'stored OAuth' produced in internal/auth. Now:

- internal/auth: new CredentialSourceOAuth constant + IsAnthropicOAuth()
- internal/ui: new UpdateUsageTrackerForModel(); CreateUsageTracker and
  SetupCLI share lookupTrackableModel (SetupCLI no longer re-inlines the
  tracker construction)
- cmd/root.go + cmd/extension_context.go: verbatim-duplicated tracker
  refresh blocks replaced with ui.UpdateUsageTrackerForModel
- pkg/kit isAnthropicOAuth delegates to auth.IsAnthropicOAuth
- internal/models compares source against the constant

* pkg/kit: consolidate model-path helpers and argument tokenizer

- ExtractModelFromPath mis-parsed model IDs containing '/' (e.g.
  'openrouter/meta/llama' -> 'meta'); it now delegates to
  RemoveProviderFromModel and is deprecated alongside
  ExtractProviderFromPath (-> GetCurrentProvider)
- parseFields delegated to prompts.ParseCommandArgs so extension argument
  parsing and builtin prompt-template parsing share one quote/escape
  grammar; ParseCommandArgs now also splits on tabs (superset of both
  previous tokenizers)

* Unify the two {{variable}} template engines

internal/skills and pkg/kit/template_bridge each had their own grammar:
skills rejected '{{ name }}' (whitespace) but allowed digit-first names;
the bridge was the opposite. A template behaved differently depending on
whether it was loaded as a skill prompt or via the extension API.

internal/skills is now the single engine using the superset grammar
(\{\{\s*(\w+)\s*\}\}); pkg/kit ParseTemplate/RenderTemplate are thin
adapters over it. Expand is now regex-based so whitespace placeholders
expand consistently; missing variables are still left as-is.

* internal/ui: extract switchModel helper for model-switch flow

The model-selector handler (ModelSelectedMsg) and /model slash command
duplicated the full switch sequence (thinking-level fallback, setModel,
display-state update, preference persistence, ModelChange emit) and had
already drifted in ordering. Both now call a single switchModel method.
Display state is still updated directly (no prog.Send from Update).

* extbridge: extract shared BaseContext for extension wiring

cmd/extension_context.go and internal/acpserver/session.go each built a
giant extensions.Context literal, duplicating ~15 delegation closures
(GetContextStats, GetMessages, AppendEntry, options, SetModel core,
Complete, SpawnSubagent, ...) that had to be kept in sync by hand. New
data-access fields had to be wired in both places or ACP-mode extensions
silently got nil function fields.

extbridge.BaseContext now provides the headless half; both call sites
overlay only their UI-specific closures. As a side effect ACP mode gains
previously-missing APIs (state, tree navigation, skills, template
parsing, model resolution) that were nil before. The interactive TUI
keeps its exact SetModel/ReloadExtensions ordering via overrides.

* internal/tools: extract withOAuthRetry and marshalToolResult helpers

ExecuteTool repeated the OAuth-error/re-auth/retry stanza verbatim twice
(sync and task-augmented paths) and the marshal-and-wrap stanza four
times. Both are now single helpers with identical error strings, so a
fix to OAuth retry or error categorization applies everywhere at once.

* internal/ui: extract buildShareFile with defer-based cleanup

handleShareCommand repeated the close/remove/print/return cleanup chain
four times across its temp-file write error paths. File assembly now
lives in buildShareFile with a single deferred cleanup on error.

* cmd: extract flag validation, preference restore, and provider-URL routing from runNormalMode

runNormalMode opened with ~150 lines of policy logic (flag-combination
validation, persisted model/thinking-level preference restoration, and
two subtle --provider-url model-rewrite rules). These are now standalone
functions (validateModeFlags, restorePersistedPreferences,
applyProviderURLRouting) so the routing policy is independently readable
and testable. Behaviour unchanged; ordering preserved.

* fix: address review findings on SDK godoc and nil guard

- pkg/kit: remove internal package paths from exported godoc on
  ParseTemplate and the ToolKind* constants (SDK doc surface must not
  reference internal packages)
- internal/tools: guard marshalToolResult against a nil CallToolResult
  (json.Marshal(nil) succeeds as 'null', then result.IsError panics if
  a client returns nil result with nil error)

Skipped the TreeNode Children deep-copy suggestion: the slice already
comes from TreeManager.GetChildren which returns a fresh copy per call
into a throwaway intermediate, so no internal state is exposed.
2026-06-11 16:13:18 +03:00
Egbert Eich ef072f6e59 Make subagent inherit tools from parent (#51)
While the tool list of the main agent could be controlled by several
options, subagent used to be equipped with all available tools (except
for the subagent tool itself).
With this change the list of tools is taken from the parent, the
subagent tool itself is removed and the remaining tool list is added
to the subagent.

Signed-off-by: Egbert Eich <eich@suse.com>
2026-06-09 16:28:01 +03:00
Ed Zynda 49f8b485be feat(extensions): add OnLLMUsage, SetState, enriched AgentEndEvent (#53) (#54)
* feat(extensions): add OnLLMUsage, SetState, enriched AgentEndEvent (#53)

Three additive primitives to the extension API:

- OnLLMUsage event: per-LLM-call token + cost deltas attributed to the
  specific model/provider used for each round-trip. Derived from the SDK
  StepFinishEvent in the extension bridge. Enables accurate budget
  enforcement between calls instead of only at turn boundaries.

- ctx.SetState / GetState / DeleteState / ListState: session-scoped,
  last-write-wins key-value store backed by a sidecar file
  (<session>.ext-state.json) outside the conversation tree. Reads are
  O(1), writes don't grow the JSONL, and the store is not duplicated on
  fork. State is preserved across hot-reloads.

- Enriched AgentEndEvent: ToolCallCount, ToolNames, LLMCallCount, token
  deltas (input/output/cache-read/cache-write), CostDelta, and
  DurationMs populated by a per-turn aggregator. Existing handlers
  reading only Response/StopReason are unaffected.

Includes unit tests for the state store, LLMUsage registration,
enriched AgentEndEvent, turn aggregator, llmUsageMeta, and sidecar path
derivation. Adds examples/extensions/usage-budget.go demoing all three
primitives together. Documents the additions in README, the docs site
(extensions overview, capabilities, examples), and the kit-extensions
and kit-sdk skill guides.

Fixes #53

* fix(extensions): address review feedback on state store and llmUsageMeta

- Serialize SetState/DeleteState saver invocations through a new saverMu
  so overlapping atomic-rename writes can no longer race on the shared
  .tmp file and persist an older snapshot after a newer one.
- LoadStateFromFile now clears the in-memory store when the sidecar is
  missing or empty, matching the documented "replace … with its
  contents" contract. This makes session-switching safe by preventing
  keys from a prior session leaking into a new one. Tests updated to
  cover both the missing-file and empty-file cases.
- llmUsageMeta now detects Anthropic OAuth credentials and returns
  Cost=0, matching the comment and the existing usage_tracker behavior
  for OAuth users. Mirrors the OAuth detection already used in
  cmd/extension_context.go.
- Document the single-in-flight-turn assumption baked into the
  per-turn aggregator with a clear migration path (per-turn ID) for if
  concurrent turns ever become a supported use case.

* fix(extensions): release saverMu on panic in state store

Extract a runSaver helper that locks saverMu and defers Unlock before
invoking the persistence callback. Without the deferred Unlock, a panic
inside the saver (e.g. disk full mid-write) would leave saverMu held
forever and deadlock the next SetState/DeleteState. Both SetState and
DeleteState now route through the helper. New TestRunner_State_Saver
PanicReleasesSaverMu reproduces the deadlock window with a 2s deadline
and proves the mutex is released after a panic.
2026-06-09 16:18:10 +03:00
Nuno do Carmo febdc530e1 Feat/copilot login (#49)
* feat(auth): add Copilot login

Add experimental GitHub Copilot device login and copilot/* provider support for users with Copilot access but no OpenAI account.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): use responses for GPT-5

Route Copilot GPT-5 models through the Responses API because gpt-5.5 is not available on /chat/completions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): honor device flow timing

* docs(copilot): add auth helper docstrings

* fix(auth): address copilot review feedback

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 00:21:20 +03:00
Ed Zynda e610bdd2d0 fix(cmd): route prefixed models through custom wire when --provider-url is set
When --provider-url was set with an explicit --model that already carried
a provider prefix (e.g. google/gemma-4-12b served by LM Studio), Kit
honored the prefix and routed through the Google wire protocol instead
of the user-supplied endpoint, producing confusing upstream errors.

- Strip any non-custom provider prefix from --model when --provider-url
  is set, so the request always lands on the OpenAI-compatible custom
  wire pointed at the user's URL.
- Leave behavior unchanged when --provider-url is absent.
- Document the rewrite in www/pages/providers.md.
2026-06-07 22:03:51 +03:00
Ed Zynda 6100e8b3a8 feat(ui): add /retry slash command for resubmitting last user message
- Add PopLastUserMessage() on *App: walks the current tree branch back to
  the parent of the most recent user message, syncs the in-memory store,
  and returns the prompt + image parts for resubmission.
- Register /retry (alias /rt) and wire handleRetryCommand which rebuilds
  the visible ScrollList from the truncated branch before resubmitting
  via Run/RunWithFiles. Mirrors SubmitMsg display path (badges, pending
  prints, stateWorking transition).
- Recovers from transient provider errors (overloaded, timeout) without
  duplicating the user message in context — the failed turn's entries
  become orphaned off-branch rather than being re-sent to the LLM.
- Update help text, AppController interface, and stub controller.
- Add unit tests covering busy/closed/no-session guards, the happy-path
  truncation, and the empty-branch error case.
2026-06-07 18:05:20 +03:00
Ed Zynda 9f125f3400 refactor(ui): standardize all popups on shared PopupList
- Extend PopupList with FullScreen mode, RenderItem callback, and
  external-state setters (SetItems/SetCursor/SetSearch) so any popup
  can reuse the same chrome (border, title, search, scroll, footer).
- Rewrite TreeSelector and SessionSelector as thin PopupList wrappers,
  dropping ~500 lines of duplicated rendering. Selector-specific keys
  (filter cycle, scope/named toggles, delete-confirm) are pre-handled;
  everything else delegates to PopupList.
- Migrate the / and @ autocomplete popups in InputComponent to render
  through PopupList, replacing the bespoke renderer.
- Fix /tree and /fork overflow with deep trees: measure tree-art
  prefix width via lipgloss.Width (handles multi-byte box drawing),
  truncate the prefix from the left with an ellipsis when it would
  push text off the row, and collapse multi-line message content to
  a single line so rows never wrap.
- Fix broken selection highlight in /tree, /fork, /sessions: emit a
  plain string from RenderItem for the cursor row so the outer row
  style paints one continuous fg+bg span instead of being shredded
  by mid-row ANSI resets from inner Render calls.
- Center the cursor in the visible window so context is always shown
  above and below the selection.
2026-06-07 17:45:06 +03:00
Ed Zynda 00eab47218 feat(ui): add /edit slash command with fuzzy file picker
- New /edit (alias /ed) opens $EDITOR on a chosen file via tea.ExecProcess
- Typing '/edit ' activates a fuzzy file popup mirroring the @ trigger:
  reuses GetFileSuggestions (git ls-files), supports directory drill-down,
  excludes MCP resources
- Selecting a file auto-submits and runs $EDITOR ($VISUAL preferred);
  on exit prints 'Edited <path>'
- Manual paths supported (~/, relative, absolute); non-existent paths
  pass through so the editor can create them; directories are rejected
- /help updated with the new command
2026-06-07 17:10:34 +03:00
Ed Zynda 06bf6d087a feat(models): resolve SDK default URLs for all registered providers
- Add sdkDefaultBaseURL map covering the 14 npm SDKs that ship a
  hard-coded baseURL (groq, cerebras, mistral, xai, perplexity,
  togetherai, deepinfra, cohere, v0, aihubmix, venice, merge-gateway,
  openrouter, vercel gateway), so providers whose models.dev entry
  omits the api field still auto-route correctly.
- Extend npmToWireProtocol so these thin OpenAI-compatible wrappers
  route through fantasy's openaicompat provider.
- Add resolveTemplatedAPIURL to substitute ${VAR} placeholders for
  cloudflare-workers-ai, databricks, snowflake-cortex from the env,
  with friendly errors that name the missing vars.
- Wire amazon-bedrock and azure-cognitive-services aliases into the
  existing native handlers; add createGoogleVertexProvider for the
  google-vertex case.
- Expose kit.ResolveProviderBaseURL in the public SDK so embedders
  can introspect the effective endpoint before instantiating a Kit.
- Refresh embedded_models.json from models.dev (5113 -> 5121 models;
  139 providers unchanged).
2026-06-07 14:06:05 +03:00
Ed Zynda fd960921ca refactor: address code audit findings across SDK, cmd, and internals
- Remove deprecated GenerateWithLoopAndStreaming and TreeManager
  AppendFantasyMessage / AddFantasyMessages / GetFantasyMessages to
  close the SDK leakage caused by the kit.TreeManager type alias
- Switch extensionAPI method signatures to local Extension* aliases so
  pkg.go.dev signatures no longer expose internal package names
- Bundle runNormalMode dependencies into a runModeDeps struct, shrinking
  the runNonInteractive and runInteractive call sites from 40+ positional
  args to (ctx, deps)
- Add generic subscribeTyped[E Event] helper and collapse ~30 typed OnXxx
  wrappers in pkg/kit/events.go onto it (public signatures unchanged)
- Extract setupBashPipes / interpretBashExit in internal/core/bash.go to
  deduplicate the buffered and streaming execution paths
- Extract resolveAutoRouteAPIKey and wrapProviderErr helpers in
  internal/models/providers.go and uniformly apply them across every
  createXxxProvider site
- Reimplement internal/extensions/watcher.go as a thin wrapper over the
  general-purpose internal/watcher.ContentWatcher, eliminating ~130 LOC
  of duplicated fsnotify logic while preserving the existing test API
- Add ctx.Err() pre-flight checks in executeRead / Write / Edit / Ls so
  cancellation actually short-circuits pure file-IO tools
2026-06-06 19:22:05 +03:00
Ed Zynda 0b651a8df9 build(deps): update dependencies except fantasy
- bump bubbletea v2.0.6 -> v2.0.7, ultraviolet, acp-go-sdk v0.13.0 -> v0.13.5
- bump indirect deps x/exp, charmtone, go-runewidth
- hold fantasy at v0.25.0 (v0.29.1 requires go 1.26.4)
- add no-op Logout method to acpserver.Agent for new acp.Agent interface
2026-06-04 15:48:07 +03:00
Ed Zynda 7315c1dea7 chore(models): update embedded model database from models.dev
- Refresh internal/models/embedded_models.json with latest data
- Add providers: alibaba-token-plan, anyapi, snowflake-cortex
- 139 providers, 5113 models total
2026-06-04 15:35:43 +03:00
Ed Zynda 0313fa03ad fix(ui): show pasted image previews in input and transcript (#48)
* fix(ui): show pasted image previews in input and transcript

The half-block thumbnail preview added in #47 rendered but was clipped
off the bottom of the screen, and submitted images showed only a text
badge in the conversation history.

- Mark the layout dirty when clipboardImageMsg / thumbnailReadyMsg reach
  the parent, so distributeHeight re-measures the now-taller input region
  instead of keeping a stale height that pushed the preview off-screen
- Render thumbnail previews in the transcript after a user message,
  appended as a verbatim ScrollList item (raw ANSI half-blocks would be
  mangled if folded into the word-wrapped user text block)
- Render transcript previews asynchronously via a tea.Cmd so decode +
  resample never blocks the Bubble Tea event loop
- Add regression tests covering the input layout recompute and the
  transcript preview flow

* fix(ui): anchor transcript image preview to its user message

- Insert the async thumbnail preview directly after the originating user
  message (tracked via anchorID) instead of appending, so a streamed
  assistant reply that lands first no longer pushes the preview out of place
- Make the layout regression test deterministic by forcing a truecolor
  profile, avoiding flakes on low-color CI terminals where the thumbnail
  would render empty
- Add tests for anchored insertion and the unknown-anchor append fallback
2026-06-04 15:30:47 +03:00
Ed Zynda d27022bcfb feat(ui): render half-block thumbnails for attached images (#47)
* feat(ui): render half-block thumbnails for attached images (#46)

- Add internal/ui/imagepreview package: Render() draws low-res
  thumbnails using Unicode half-blocks (▀) + truecolor/256-color SGR,
  which survives tmux/zellij (no graphics protocol)
- Cache a rendered thumbnail per pending clipboard image in the input
  component; render once at attach time, never per frame
- Fall back to the existing [N image(s) attached] text pill when the
  terminal lacks truecolor/256-color support
- Document Ctrl+V paste, Ctrl+U clear, and the preview in the docs
  site and README keyboard shortcuts

Fixes #46

* fix(ui): render image thumbnails off the event loop and cap size

- Render thumbnails asynchronously via a tea.Cmd instead of calling
  the decode + resample path synchronously inside Update(), which
  blocked the Bubble Tea event loop
- Add thumbnailReadyMsg + an imageGen generation counter so async
  results land on the correct pendingImages slot and stale renders
  after a clear/re-attach are discarded
- Guard imagepreview.Render against decompression bombs by checking
  DecodeConfig dimensions against a max before full decode

* fix(ui): skip image preview when input width is too small

- Return 0 from thumbCols when width <= 6 so a full-size thumbnail is
  no longer rendered for tiny or uninitialized (width 0) terminals;
  the caller falls back to the text pill
2026-06-04 14:36:39 +03:00
Ed Zynda ae722d520f fix(models): route auto-discovered providers by wire protocol (#41) (#43)
- replace npmToLLMProvider map with npmToWireProtocol (openai/anthropic/google)
- add createAutoRoutedGoogleProvider so @ai-sdk/google proxies work
  (fixes opencode/gemini-* failing with "no LLM provider mapping")
- strip the genai-injected v1beta segment for proxies whose base URL
  already carries a version (e.g. opencode's /zen/v1)
- preserve openai-compat fallback and clearer error for unroutable providers
- document auto-routing in README and providers docs; update CreateProvider godoc
- add regression tests for wire routing and version-path rewriting

Fixes #41
2026-06-02 15:21:48 +03:00
Ed Zynda 7a04bdfeba feat(kit): isolate viper config per Kit instance + add NewAgent (#42)
* feat(kit): isolate viper config per Kit instance + add NewAgent (#40)

- Give each kit.New()/NewAgent() call an isolated *viper.Viper store so
  multiple Kit instances in one process no longer clobber each other's
  config; runtime mutators (SetModel, SetThinkingLevel) touch only the
  owning instance, making subagent spawning and multi-Kit embedding
  race-free
- Thread the per-instance store through internal/config, internal/models
  (ProviderConfig.ConfigStore), internal/kitsetup, and the extension
  runner, with a nil -> process-global fallback so the CLI is unaffected
- Share the global store when Options.CLI != nil to preserve cobra flag
  bindings (also opted in for internal/acpserver)
- Remove viperInitMu; preserve the tri-state IsSet precedence contract
  and sdkDefaultMaxTokens floor
- Add ergonomic NewAgent + functional options (WithModel, WithStreaming,
  Ephemeral, etc.); NewAgent defaults streaming on, opt out via
  WithStreaming(false). New(ctx, *Options) behavior is unchanged
- Add config-isolation regression test and NewAgent/option coverage;
  document NewAgent and per-instance isolation in README

Fixes #40

* docs(sdk): document NewAgent options and per-instance config isolation

- Add "Functional options (NewAgent)" and "Per-instance config isolation"
  sections to the docs site SDK overview, with an options table and a
  "when to use which" constructor comparison
- Cross-reference NewAgent from the SDK options page and correct the now
  per-instance ProviderAPIKey precedence wording
- Document NewAgent + With* helpers and config isolation in pkg/kit/README
  and list NewAgent/Option in the API reference
- Show the NewAgent constructor in the SDK examples getting-started snippet

* fix(kit): correct config loading and isolate ACP sessions

- Isolate each ACP session's config store instead of sharing the global
  viper, preventing per-session SetModel/SetThinkingLevel races; seed the
  root-command flag values (model, thinking-level, provider URL/key) so
  `kit acp -m <model>` is still honored
- Run initConfig for isolated SDK stores by gating on opts.CLI instead of
  v.GetString("model"), which setSDKDefaults always populates and thus
  skipped .kit.yml / KIT_* loading for SDK callers
- Configure KIT_* env overrides unconditionally in initConfig so passing an
  explicit config file no longer disables environment variable support
- Wrap config unmarshal/validate errors with %w to preserve the error chain

* fix(kit): make Options.Streaming a *bool to honor unset

- Change Options.Streaming from bool to *bool so a zero-valued Options no
  longer forces stream=false; New only sets the key when non-nil, letting
  streaming resolve through the precedence chain (env -> config -> default
  true). This also fixes the CLI path, which never set the field
- Mirror the existing sampling-parameter pointer pattern instead of adding
  a separate StreamingSet sentinel, keeping Options internally consistent
- Update WithStreaming/NewAgent, subagent, and ACP callers to the pointer
  form; add regression tests for the nil-default and explicit opt-out paths
- Update SDK docs (README, pkg/kit/README, options page) with the ptrBool
  helper and *bool semantics

* fix(kit): inherit parent provider config in subagents

- Copy the parent's effective provider/runtime config (API key, URL,
  TLS, thinking level, max-tokens, samplers) onto child Options in
  Kit.Subagent. After the per-instance viper isolation, the child's
  isolated store only re-loaded .kit.yml / KIT_*, silently dropping
  config the parent set via programmatic Options or runtime setters
  like SetThinkingLevel
- Preserve the IsSet tri-state for max-tokens and samplers so per-model
  defaults still apply on the child when the parent left them unset
- Add TestInheritProviderConfig covering propagation, unset keys, and
  nil-safety
2026-06-02 14:41:35 +03:00
Sai Karthik 7e4708f511 docs(config): add example support of custom headers for mcp servers (#39)
docs(config): implement suggested improvements
2026-06-02 14:12:01 +03:00
Sai Karthik 1e12102b92 CLI & Config changes to support disabling core tools (#35)
* feat(core): expose no-core-tools via CLI flag and config file

Allow users to disable all built-in core tools (bash, read, write,
edit, grep, find, ls, subagent) without recompiling, using a CLI flag,
environment variable, or .kit.yml config key.

Changes
-------
cmd/root.go
  - Declare noCoreToolsFlag bool alongside noExtensionsFlag.
  - Register --no-core-tools persistent flag with a descriptive help string
    listing the affected tools.
  - Bind the flag to viper key "no-core-tools" so the config file and
    KIT_NO_CORE_TOOLS env var also work (viper's standard precedence:
    CLI flag > env var > config file > default).
  - Set kitOpts.DisableCoreTools = viper.GetBool("no-core-tools") when
    assembling the Options struct in runNormalMode.

pkg/kit/kit.go
  - Add disableCoreTools local variable inside the viperInitMu-protected
    snapshot block, mirroring the noExtensions pattern exactly.
  - Resolve it as opts.DisableCoreTools || viper.GetBool("no-core-tools")
    so the SDK option and the viper key are both respected (OR semantics:
    either source can enable the flag).
  - Pass the resolved disableCoreTools into kitsetup.AgentSetupOptions
    instead of the raw opts.DisableCoreTools, completing the chain.

Usage
-----
  # CLI flag
  kit --no-core-tools

  # Environment variable
  KIT_NO_CORE_TOOLS=true kit

  # .kit.yml config file
  no-core-tools: true

  # SDK (unchanged, was already supported)
  kit.New(ctx, &kit.Options{DisableCoreTools: true})

The downstream path (kitsetup → agent.AgentConfig.DisableCoreTools →
agent.NewAgent nil-tool branch) was already in place and required no
changes.

* docs(readme): document no-core-tools flag, config key, and env var

Update three locations in README.md to reflect the new no-core-tools
control surface introduced in the previous commit:

CLI Reference → Global Flags
  Add --no-core-tools under the Extensions and tools section alongside
  --no-extensions, with a description listing the affected tools.

Configuration → Basic Configuration
  Add no-core-tools: false to the example .kit.yml block so users know
  it is a valid config file key (equivalent to the CLI flag and env var).

Go SDK → With Options
  Expand the DisableCoreTools comment to note that the same behaviour is
  also available via --no-core-tools, KIT_NO_CORE_TOOLS, and
  no-core-tools: true in .kit.yml, making the cross-surface relationship
  explicit for SDK consumers.

* style: gofmt cmd/root.go
2026-05-29 20:33:05 +03:00
Ed Zynda ab2a77c95e feat(sdk): runtime skills and context-file management (#36) (#37)
* feat(sdk): runtime skills and context-file management (#36)

Let SDK consumers add, remove, and replace skills and AGENTS.md-style
context files after Kit construction. Every mutation recomposes the
system prompt and applies it to the agent so the next turn picks up
the new instructions without restarting Kit.

- AddSkill / LoadAndAddSkill / RemoveSkill / SetSkills on *kit.Kit
- AddContextFile / AddContextFileContent / LoadAndAddContextFile /
  RemoveContextFile / SetContextFiles on *kit.Kit
- RefreshSystemPrompt to force a manual recomposition
- agent.SetSystemPrompt / GetSystemPrompt on the internal agent so
  the composed prompt rebuilds the fantasy agent on the next call
- Per-instance runtimeMu guards skills/contextFiles; GetSkills and
  GetContextFiles return defensive snapshots safe for concurrent use
- Capture the resolved basePrompt during New so recomposition keeps
  per-model overrides and --system-prompt file resolution intact
- Skills dedupe by Name; context files dedupe by Path (opaque ID,
  not required to be a real filesystem path)

Tests cover add/remove/set/replace semantics, validation errors,
disk loading round-trips, prompt composition, and an 8-goroutine
race-stress sweep (go test -race clean).

Docs: pkg/kit/README, root README Go SDK section, www sdk/overview
"Runtime skills and context files" section, www sdk/options callout
cross-referencing the new API.

Fixes #36

* fix(agent): synchronize SetSystemPrompt against concurrent rebuilds

- add promptMu to Agent guarding systemPrompt writes and the fantasy
  agent rebuild, fixing a data race when Kit.applyComposedSystemPrompt
  is invoked concurrently
- read systemPrompt under the same lock in GetSystemPrompt
- update the thread-safety stress test to use a non-nil agent so the
  SetSystemPrompt path is actually exercised under -race
2026-05-29 18:44:12 +03:00
Ed Zynda 1e78153b50 ci(release): revert goreleaser parallelism workaround
No longer needed now that fantasy is pinned to v0.25.0 — build memory is
back to ~1GB, well under the runner's 7GB limit.
2026-05-29 17:52:29 +03:00
Ed Zynda a613361969 fix(deps): pin fantasy to v0.25.0 to avoid CLDR compile-memory blowup
fantasy v0.25.1+ bumps kaptinlin/jsonschema to v0.7.14+, which transitively
pulls in github.com/agentable/go-intl. Its internal/cldr/displaynames
package contains a ~143k-line / 5.4MB generated CLDR map literal that
compiles at ~6.7GB RSS, OOM-killing release builds on 7GB GitHub runners.

Pinning fantasy v0.25.0 (jsonschema v0.7.13, go-i18n v0.4.5,
messageformat-go v0.6.0) removes go-intl from the build graph entirely and
restores clean-build peak RSS from ~6.6GB back to ~1GB. Upstream issue filed
against charmbracelet/fantasy.
2026-05-29 17:44:34 +03:00
Ed Zynda 67722b0c24 ci(release): limit goreleaser parallelism to avoid OOM on runner
A single clean cross-build peaks at ~7GB RSS (internal/extensions yaegi
symbol table). goreleaser builds targets in parallel by default, which
exhausts the 7GB ubuntu-latest runner and OOM-kills the build with no
error output. Force --parallelism 1 and cap go compiler with GOFLAGS=-p=2.
2026-05-29 16:42:28 +03:00
Ed Zynda 1a2f6da40f chore(models): refresh embedded models database from models.dev
- Bump provider count from 131 to 136
- Bump model count from 4817 to 4965
2026-05-29 15:09:26 +03:00
Ed Zynda 747f5be099 build(deps): bump all dependencies to latest
- fantasy v0.25.0 -> v0.27.0
- chroma v2.24.1 -> v2.26.1
- mcp-go v0.54.0 -> v0.54.1
- ultraviolet, charmbracelet/x snapshots refreshed
- aws-sdk-go-v2 family, smithy-go v1.25.1 -> v1.26.0
- opentelemetry v1.43.0 -> v1.44.0 (+ otelhttp/otelgrpc v0.69.0)
- google.golang.org/api v0.279.0 -> v0.282.0, genai v1.57.0 -> v1.58.0
- kaptinlin/jsonschema, jsonpointer, messageformat bumps
- golang.org/x/{crypto,net,sys,exp} updates
2026-05-29 11:57:20 +03:00
Ed Zynda d7c4565999 refactor: remove dead code, fix SDK leakage, deduplicate helpers
- Remove unused SetOpenAICredentials/validateOpenAIAPIKey (internal/auth)
- Remove unused SudoPasswordRequiredMetadata/IsSudoPasswordRequiredResult
  (internal/core)
- Add Extension* type aliases in pkg/kit/extension_api.go so the public
  ExtensionAPI interface no longer exposes internal/extensions types
- Extract bridgeObserve generic helper and llmToContextMessages /
  contextMessagesToLLM in pkg/kit/extensions_bridge.go (~150 lines saved)
- Extract parseHeaders and buildOAuthConfig in connection_pool.go to
  deduplicate SSE/Streamable client construction (~60 lines saved)
- Eliminate redundant second buildInteractiveExtensionContext call in
  cmd/root.go; swap print closures on the same context instead
- Replace 'Fantasy' with 'agent' in internal comment (pkg/kit/kit.go)
2026-05-25 13:30:22 +03:00
Ed Zynda bd24f3315c fix(agent): track tool call args per ToolCallID for parallel calls (#33) (#34)
Previously GenerateWithCallbacks stored the most recent tool call's args
in a single shared variable, which got clobbered when a provider emitted
multiple tool_use blocks in a single step. Every OnToolResult callback
then received the args of the last OnToolCall, regardless of which call
it was actually resolving — breaking any downstream UI, log, or trace
that derived its description from the toolArgs parameter.

- Replace the shared currentToolArgs with a map keyed by ToolCallID,
  guarded by a sync.Mutex in case the streaming layer dispatches
  callbacks from multiple goroutines.
- Delete each entry in OnToolResult so the map cannot accumulate
  across steps.
- Add a regression test driving the streaming wrapper with a fake
  fantasy.Agent that emits two parallel tool calls before either
  result, asserting each callback sees its own args.

Fixes #33
2026-05-20 10:37:46 +03:00
Ed Zynda 592f8dc84f chore(models): refresh embedded models.dev snapshot
- Sync internal/models/embedded_models.json from https://models.dev/api.json
- Providers: 114 → 131 (+17)
- Models: 4276 → 4817 (+541)
2026-05-19 15:11:01 +03:00
Ed Zynda 66c4a1eb15 build(deps): bump all dependencies and go directive to 1.26.3
- charm.land/fantasy v0.23.0 -> v0.25.0
- charmbracelet/ultraviolet -> 20260511
- coder/acp-go-sdk v0.12.2 -> v0.13.0
- mark3labs/mcp-go v0.51.0 -> v0.54.0
- kaptinlin/{go-i18n,jsonpointer,jsonschema,messageformat-go} bumps
- golang.org/x/{crypto,net,sys,term,text} minor bumps
- google.golang.org/{api,genai,genproto,grpc} bumps
- charmbracelet/x/exp/{charmtone,slice}, tidwall/gjson, others
- go directive bumped to 1.26.3 (required by fantasy v0.25.0)

No code changes required; build, vet, and race tests all pass.
2026-05-19 13:24:53 +03:00
Ed Zynda 5104477631 perf(session): parallelize session list extraction
Open the /resume session picker faster by extracting per-file metadata
across a GOMAXPROCS-sized worker pool instead of sequentially. Each
extractSessionInfo call is I/O + JSON-parse bound and independent, so
wall time drops roughly proportionally to core count — meaningful for
users with many sessions, where ListSessions + ListAllSessions ran
back-to-back on the UI goroutine before the picker rendered.
2026-05-16 16:19:38 +03:00
Ed Zynda 394a4676a1 fix(app): deliver trailing widget update so layout resets after removal
- Switch NotifyWidgetUpdate from leading-only to leading+trailing edge
  coalescing so a rapid SetWidget→RemoveWidget pair (e.g. emitted by
  subagent-monitor on SubagentEnd) is never silently dropped.
- Without the trailing send the TUI keeps the pre-removal widget
  height, leaving empty rows below the status bar until some other
  event re-renders the layout.
2026-05-16 14:07:58 +03:00
Ed Zynda 30f2bc243d fix(ui): correct mouse selection drift with extension widgets
- Match View() and getItemAndLineAtY() row counts for empty items so
  streaming-reasoning placeholders no longer offset hit-testing by one
  row each (exposed when extension widgets like subagent-monitor shrink
  the scrollback).
- Honor IsLineInRange's endCol=-1 'to end of line' sentinel in
  HighlightLine and ExtractText so the start row of a multi-line drag
  actually renders highlighted and is included in clipboard copies.
- Add regression tests for both invariants in scrolllist and selection.
2026-05-16 13:48:51 +03:00
Ed Zynda 922e246098 feat(prompts): auto-reload prompts and extensions from XDG config
- Add prompts.GlobalDir() resolving $XDG_CONFIG_HOME/kit/prompts/
  (default ~/.config/kit/prompts/) so prompt templates live alongside
  extensions and skills under the same XDG-aligned root.
- LoadAll now discovers templates from both the legacy ~/.kit/prompts/
  and the XDG location; existing legacy paths keep precedence.
- Include GlobalDir() in the prompts/skills file watcher so edits
  under ~/.config/kit/prompts/ hot-reload automatically.
- Surface a visible 'Extensions reloaded.' (or error) message when
  the extension watcher fires, matching /reload-ext feedback.
- Restore examples/extensions/subagent-monitor.go alongside its test
  and update the test load path; previous move left the test broken.
2026-05-15 14:31:51 +03:00
Ed Zynda 32b6376515 chore: move go-edit-lint extension to global scope
- Remove .kit/extensions/go-edit-lint.go from the repo since the
  extension is now installed under ~/.config/kit/extensions/ for
  per-user use across all projects.
2026-05-15 14:18:57 +03:00
Ed Zynda cf194ff89a feat(ui): list loaded extensions in startup banner
- Add ExtensionInfo type and Loaded() method to the public ExtensionAPI
  so SDK consumers can inspect which extensions are active.
- Introduce ui.ExtensionItem and thread ExtensionItems/GetExtensionItems
  through AppModelOptions, mirroring the existing SkillItem pattern.
- Render an [Extensions] row in AddStartupMessageToScrollList showing
  the filename of each loaded extension (with a (N tools) suffix when
  extensions register tools). Falls back to tool count only when items
  are unavailable, and is omitted entirely when no extensions load.
- Refresh the list on /reload-ext via a new refreshExtensionItems hook
  so the banner stays accurate across hot-reloads.
- Add buildExtensionItems helper in cmd/root.go that strips .go and
  resolves subdirectory extensions to their parent dir name, tagging
  each as project or user scope based on cwd.
2026-05-15 14:08:42 +03:00
Ed Zynda 03006425fa cleanup 2026-05-15 13:55:32 +03:00
Ed Zynda a322dfc59a fix(ui): eliminate mouse copy-selection drift during streaming
- Lock viewport scroll while a drag-select is active so highlighted
  content stays under the cursor (SetItems, appendStreamingChunk,
  MouseWheelDown all now honor IsMouseDown).
- HandleMouseDrag defensively clears autoScroll on every update so a
  racy re-enable can't shift the row mid-drag.
- Recompute scrollback yOffset/viewport height on each mouse event
  via currentScrollbackBounds() instead of relying on stale values
  cached during the previous View() pass.
- Account for canceling/ctrlCPressedOnce warning rows in
  distributeHeight and mark layoutDirty when those flags toggle so
  the height budget and mouse origin stay in sync.
- Add ScrollList regression tests covering the three invariants.
2026-05-15 13:30:57 +03:00
Ed Zynda b1387d837e feat(ui): add /copy slash command to copy last message
- Register /copy (alias /cp) in the System command category
- Walk the scrollback to find the last user/assistant/reasoning
  message, skipping transient system messages
- Reuse internal/ui/clipboard.CopyToClipboard for OSC 52 + native
  clipboard support (works over SSH)
- Document the command in /help
2026-05-15 13:06:35 +03:00
Ed Zynda f561f4cfd9 fix(session): order kept messages before post-compact branch in BuildContext
After /compact, BuildContext emitted [summary, post-compact, kept]
which placed an older kept user/assistant turn after the latest
post-compaction turn. This broke user/assistant alternation and caused
the model to respond as if the post-compaction turn never happened on
the next user message.

- Emit kept messages chronologically before post-compaction messages
- Mirror the same order in GetContextEntryIDs so cut-point to entry-ID
  mapping stays aligned across repeat compactions
- Update TestCompactionWithNewMessagesAfterCompaction to assert the
  correct chronological order
2026-05-14 20:42:20 +03:00
Ed Zynda 64caed57d4 fix(sdk): stop leaking fantasy types through pkg/kit.AgentConfig (#30) (#32)
* fix(sdk): stop leaking fantasy types through pkg/kit.AgentConfig (#30)

Replace the alias-based AgentConfig and handler types with SDK-owned
structs and function types. CoreTools / ExtraTools / ToolWrapper now
accept []kit.Tool, and the handler types (ToolCallHandler,
ToolExecutionHandler, ToolResultHandler, ResponseHandler,
StreamingResponseHandler, ToolCallContentHandler) plus SpinnerFunc are
declared in pkg/kit/ with signatures that reference only SDK types.

Consumers no longer need to import charm.land/fantasy to populate an
AgentConfig or assign a handler. go doc pkg/kit AgentConfig output no
longer mentions fantasy.*.

- Add unexported (*AgentConfig).toInternal() to convert at the SDK
  boundary; Tool is still an alias for the underlying tool type, so
  slice and function fields convert without allocation.
- Add agent_config_internal_test.go covering nil receiver, scalar
  fields, tool slices, ToolWrapper invocation, OnMCPServerLoaded, and
  auth/token-factory wiring.
- Add types_test.go cases that populate AgentConfig and SpinnerFunc
  without importing fantasy -- the file compiling is the regression
  proof for the leak.
- Update pkg/kit/README.md Re-exported Types section to record that
  AgentConfig and the handler types are now Kit-owned.

Fixes #30

* fix(sdk): add DebugLogger and MCPTaskConfig to kit.AgentConfig (#30)

The first revision of the SDK-owned AgentConfig dropped two fields that
internal/agent.AgentConfig carried: DebugLogger (tools.DebugLogger) and
MCPTaskConfig (tools.MCPTaskConfig). Restore them with SDK-owned
equivalents and wire them through toInternal().

- Add kit.DebugLogger interface (LogDebug / IsDebugEnabled) mirroring
  tools.DebugLogger. Interface-to-interface assignment is automatic
  because the method sets match.
- Add kit.MCPTaskConfig struct mirroring tools.MCPTaskConfig with SDK
  types (MCPTaskMode, MCPTaskProgressHandler) and a toToolsConfig()
  helper that converts at the SDK boundary.
- Wire both new fields in (*AgentConfig).toInternal().
- Extend agent_config_internal_test.go with cases for both fields.
- Document the additions in pkg/kit/README.md.
2026-05-13 21:10:28 +03:00
Ed Zynda 975c30a773 fix(mcp): surface MCP tool failures as soft errors, not critical aborts (#31)
The MCP adapter previously wrapped any error returned by MCPToolManager.ExecuteTool
into a Go error returned from the fantasy.AgentTool.Run interface. The fantasy
agent loop treats those as critical errors and aborts the entire turn —
discarding all prior reasoning, tool calls, and results.

In practice that meant a single misbehaved MCP server returning a JSON-RPC
"-32602 Invalid params" (e.g. a Zod schema mismatch on the server's input
validation) would kill an in-progress turn after the model had already done
dozens of seconds of useful work, with no way for the model to see the
validation message and self-correct.

This mismatched the contract that native Kit tools follow: native tools
return errors via kit.ErrorResult(...), which become soft tool-result errors
that the model reads and can act on (retry with corrected args, try a
different tool, give up gracefully).

Make the MCP path behave the same way:

  - JSON-RPC protocol errors, transport failures, and server-side schema
    rejections are now returned as fantasy.NewTextErrorResponse(...) with
    err == nil, so the agent loop continues and the model sees the failure
    in-band as a tool result it can reason about.
  - Context cancellation (ctx.Err() != nil) remains a critical error so
    callers can abort turns deterministically. This is the only case where
    bubbling up is correct — the caller intentionally tore the turn down
    and the agent must not keep spinning.
  - Server-side soft errors (CallToolResult{ isError: true }) and the
    happy path are unchanged.

The agent loop's MaxSteps cap already bounds the worst case for a
permanently broken MCP server, so there is no risk of unbounded retries.

Side effect: extracted a tiny mcpExecutor interface for the one method the
adapter uses (ExecuteTool), purely so the adapter is unit-testable in
isolation without standing up a full MCPToolManager + connection pool.

Behavior change note for downstream consumers: code that relied on
host.PromptResult / Stream returning a Go error containing
"mcp tool execution failed" will no longer see those errors — the
failure information is now in the assistant's final response (or in the
OnAfterToolResult / OnToolResult hooks, where IsError will be true).
Context cancellation continues to surface as an error from those calls
as before.

Co-authored-by: space_cowboy <space_cowboy@mark3labs.com>
2026-05-13 20:12:31 +03:00
Ed Zynda 35b9360d64 feat(ui): autocomplete /skill:<name> slash commands
- register loaded skills into the input autocomplete under category
  "Skills" with HasArgs so Enter populates "/skill:name " instead of
  auto-submitting, leaving room for trailing args
- prefix descriptions with [project] or [user] to disambiguate
  colliding skill names across sources
- extend refreshSkillItems to prune & re-add Skills entries on
  ContentReloadEvent, matching the pattern used for prompt templates
  and MCP prompts
- add Description field to ui.SkillItem and populate it from
  kit.Skill.Description in both initial build and hot-reload paths
2026-05-13 15:35:07 +03:00
Ed Zynda 1b8373e133 cleanup 2026-05-12 13:30:30 +03:00
Ed Zynda 1a5e4ce7c5 Merge pull request #29 from mark3labs/fix/27-queued-messages-after-compact
test(app): cover steer-drain branch of releaseBusyAfterCompact
2026-05-08 13:11:45 +03:00
Ed Zynda 8823977612 test(app): cover steer-drain branch of releaseBusyAfterCompact
- Add unexported steerDrainFn test seam on App so unit tests can
  inject fake steer items without standing up a full *kit.Kit
  (Options.Kit is a concrete struct, not an interface).
- releaseBusyAfterCompact now prefers the seam over Kit.DrainSteer
  via a small switch; production behaviour is unchanged when the
  field is nil.
- Add TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue, which
  pre-populates both fake steer items and ordinary queue prompts,
  invokes releaseBusyAfterCompact, and asserts the first dispatched
  prompt is the steer item — proving steer messages retain 'act now'
  priority and that drainQueue is actually launched (the bug from
  #27).
2026-05-08 12:18:52 +03:00
Ed Zynda 24e2ea111c Merge pull request #28 from mark3labs/fix/27-queued-messages-after-compact
fix(app): flush queued messages after /compact completes (#27)
2026-05-08 12:16:28 +03:00
Ed Zynda 31ea80ec4f fix(app): flush queued messages after /compact completes (#27)
- Add releaseBusyAfterCompact() shared deferred tail used by both
  CompactConversation and CompactAsync. It drains the SDK steer
  channel, splices steer items in front of any queued prompts, and
  hands off to drainQueue so messages received during compaction
  are dispatched automatically once compaction finishes.
- Previously, busy was simply cleared on completion and the queue
  sat idle until the user submitted another prompt, which then
  flushed everything together.
- Honor the closed flag so a teardown during compaction discards
  pending items instead of spawning drainQueue against a torn-down
  App.
- Add regression tests covering the queued-flush, idle-empty, and
  closed-during-compact paths.

Fixes #27
2026-05-08 11:30:26 +03:00
Ed Zynda 99f2680c2e Merge pull request #26 from mark3labs/fix/25-system-prompt-file-path
fix(kit): resolve system-prompt file path before PromptBuilder (#25)
2026-05-08 10:54:09 +03:00
Ed Zynda da7e05eb87 fix(cmd): nil-guard CLI when emitting system-prompt notice in quiet mode
SetupCLIForNonInteractive returns nil when --quiet is active, matching
the pre-existing nil checks elsewhere in the same block (e.g. the
buffered debug-message branch). Without this guard the new
'System Prompt loaded' notice panicked on quiet, non-interactive runs.

Discovered via tmux smoke test of the #25 fix.
2026-05-08 10:44:01 +03:00
Ed Zynda a95714a22d fix(kit): resolve system-prompt file path before PromptBuilder (#25)
When system-prompt was a file path (via --system-prompt, config entry,
or SDK Options.SystemPrompt), the path string itself was used as the
base prompt because config.LoadSystemPrompt only ran later in
BuildProviderConfig — by which point viper had been overwritten with
the path-augmented composed text. The LLM received the path instead of
the prompt contents.

- Call config.LoadSystemPrompt on the raw viper value in New() before
  PromptBuilder composes runtime context (AGENTS.md / skills / date).
- Add HasCustomSystemPrompt() and GetSystemPromptSource() so SDK callers
  can inspect prompt state without reaching into viper.
- Display 'System Prompt loaded: <source>' at startup in CLI and TUI
  modes, paralleling the per-server 'MCP server loaded' notice.
- Add regression tests covering both file-path and inline prompt paths.

Fixes #25
2026-05-08 10:39:14 +03:00
Ed Zynda c4a2b0f1a3 Merge pull request #24 from mark3labs/audit-cleanup
refactor: remove dead code and consolidate duplicated extension wiring
2026-05-07 17:46:49 +03:00
Ed Zynda 2016570e2d test: add docstrings to rewritten tests and use t.Setenv
Addresses two CodeRabbit feedback items on PR #24:

* Docstring coverage warning (was 57.14%, threshold 80%): adds godoc
  comments to the four test functions added or substantially rewritten
  in this PR — TestLoadAndSaveManifest, TestAddAndRemoveFromManifest,
  TestFindInManifest, TestHighlightFileTokensInjectsANSI.
* Quick-win nitpick: replaces the manual os.Setenv/os.Unsetenv +
  defer pattern in TestFindInManifest with t.Setenv, which restores
  the env var automatically on cleanup even on panic or t.Fatal.

go test -race ./... still passes.
2026-05-07 13:16:03 +03:00
Ed Zynda d557f4b870 fix(cmd): wrap bare fn refs in extensions.Context as closures
Per AGENTS.md 'Yaegi function field bug', named function/method
references assigned to extensions.Context fields return zero values
across the interpreter boundary. The two SetContext literals in
runNormalMode (now consolidated in buildInteractiveExtensionContext)
inherited 9 bare references that need to be anonymous closure literals:

  PrintBlock, GetChildren, GetAvailableSkills, ParseTemplate,
  RenderTemplate, ParseArguments, SimpleParseArguments,
  ResolveModelChain, CheckModelAvailable

Each is now wrapped as 'func(args) ret { return <orig>(args) }'.
Behaviour unchanged in regular Go; Yaegi extensions that consume these
fields will now see callable closures instead of zero values.

Verified with go test -race ./...
2026-05-07 13:00:06 +03:00
Ed Zynda 65054fe3db gofmt trailing-blank-line cleanup after dead-code removal 2026-05-07 12:34:29 +03:00
Ed Zynda 97d2246375 drop orphan testTypography helper from render tests
The TestUserBlockHighlightsFileTokens test was rewritten to call
HighlightFileTokens directly (UserBlock was deleted in the dead-code
sweep). That left testTypography with no callers, so staticcheck U1000
flagged it.
2026-05-07 12:31:55 +03:00
Ed Zynda 1e12505741 remove unused style.BaseStyle helper 2026-05-07 12:29:59 +03:00
Ed Zynda 6755597c9b extract buildInteractiveExtensionContext helper
The previous runNormalMode contained two nearly-identical 400-line
extensions.Context literal expressions:

  * the startup-time literal (cmd/root.go:853-1307) that buffered
    Print* calls into startupExtensionMessages
  * the runtime literal (cmd/root.go:1311-1605) that routed Print*
    through appInstance.PrintFromExtension

Every other field — Compact, SendMultimodalMessage, the four prompt
factories, all 25+ data-access fields, all four bridge phases — was
duplicated byte-for-byte. Maintainers had to remember to update both
copies whenever an extension Context field was added.

cmd/root.go is now 1463 lines (was 2225). The new helper lives in
cmd/extension_context.go (455 lines, mostly the closures verbatim) and
returns an extensions.Context with every field populated except
Print/PrintInfo/PrintError, which each call site sets afterwards to
match its phase. This preserves AGENTS.md's 'function field bug'
guarantee — all assignments remain anonymous closure literals.

Output of 'kit --version' / 'kit --help' unchanged. Full test suite
passes.
2026-05-07 12:28:18 +03:00
Ed Zynda 45689cb30d extract duplicated subagent + event conversion to internal/extbridge
The same ~40-line block — building a kit.SubagentConfig, wrapping
OnEvent through sdkEventToSubagentEvent, calling kitInstance.Subagent,
and translating the SDK result into extensions.SubagentResult — was
copy-pasted three times:

  * cmd/root.go (interactive TUI Context, line 1148)
  * cmd/root.go (post-SessionStart runtime Context, line 1446)
  * internal/acpserver/session.go (ACP server Context, line 154)

A separate sdkEventToSubagentEvent function was duplicated byte-for-byte
between cmd/root.go and internal/acpserver/session.go.

Both are now consolidated in a new internal/extbridge package which is
the only module-internal home that can legitimately import both
pkg/kit/ (the public SDK) and internal/extensions/. cmd/ and
internal/acpserver/ both import it, so SDK-event-to-extension-event
schema changes only have one site to update.

Also fixes pkg/kit/events.go godoc comment that named the underlying
LLM library, per AGENTS.md 'No Dependency Name Leakage' rule for
exported SDK symbols.

go test -race ./... passes.
2026-05-07 12:23:15 +03:00
Ed Zynda 78570d4188 remove dead code identified by audit
Removes ~600 lines of unreferenced code surfaced by deadcode + manual
audit (none of it reachable from production code paths or test setup):

- internal/models/pool.go: ProviderPool was never wired into kitsetup
  or the agent; the global pool singleton had zero callers.
- internal/ui/debug_logger.go: CLIDebugLogger was unreachable; debug
  routing goes through internal/tools/buffered_logger.go instead.
- internal/ui/tool_approval_input.go: tea.Model never instantiated;
  approvals are handled inline in model.go.
- internal/ui/cli.go: DisplayAssistantMessage / DisplayCancellation /
  GetDebugLogger had zero callers (the *WithModel variant is what
  event_handler.go uses).
- internal/ui/style/enhanced.go: Style{Card,Header,Subheader,Muted,
  Success,Error,Warning,Info} + Create{Separator,ProgressBar} — none
  used. CreateBadge stays (used by model.go).
- internal/ui/style/themes.go: RefreshThemeRegistry — never called.
- internal/ui/block_renderer.go: With{FullWidth,MarginTop,Padding{Left,
  Right},Background,Foreground,Width} — option helpers nobody calls.
- internal/ui/render/blocks.go: UserBlock, ToolBlock — replaced by
  inline rendering elsewhere; the test for UserBlock was rewritten to
  directly exercise HighlightFileTokens (which is what the test really
  cared about).
- internal/ui/commands/commands.go: GetAllCommandNames — no callers.
- internal/ui/message_items.go: NewTextMessageItem,
  NewSystemMessageItem + the entire SystemMessageItem type — model.go
  uses NewStyledMessageItem instead.
- internal/prompts/loader.go: Deduplicate — the loader does dedup
  internally; standalone helper was unused.
- internal/models/cache_options.go: mergeProviderOptions + its
  test-only consumer.
- internal/extensions/installer.go: Installer.GetInstalledPackages —
  intended for a 'kit ext list' command that was never built.
- internal/extensions/manifest.go: saveManifestToScope,
  saveManifestToPath, GetGlobalManifest, GetProjectManifest,
  addEntryToManifest, removeEntryFromManifest — package-level
  duplicates of *Installer methods. Tests rewritten to exercise the
  live Installer methods instead, which fixes a latent path-resolution
  inconsistency between manifestPathForScope and Installer.manifestPath
  (the former hard-coded paths, the latter respects projectGitRoot).
- internal/extensions/subagent.go: SpawnSubagent + helpers
  (generateSubagentID, findKitBinary, subagentJSONOutput). The
  subprocess-spawn implementation is unreachable; production code
  routes through kit.Kit.Subagent (in-process). Types
  (SubagentConfig/Result/Handle/etc.) and the SubagentHandle methods
  remain because they are exposed to extensions via Yaegi symbols and
  the Context.SpawnSubagent field.
- cmd/root.go: LoadConfigWithEnvSubstitution — one-line wrapper around
  kit.LoadConfigWithEnvSubstitution with zero callers.

go test -race ./... passes.
2026-05-07 12:20:08 +03:00
Ed Zynda 7cf38b37ee Merge pull request #23 from mark3labs/fix/18-windows-session-dir-colon
fix(session): strip illegal characters from windows session dir (#18)
2026-05-07 11:13:34 +03:00
Ed Zynda 4ef57eec4e docs(session): correct DefaultSessionDir convention comment
- Stale comment showed ~/.kit/sessions/--<cwd-path>--/ which does not
  match the actual encoding (no leading/trailing dashes)
- Update to reflect the real format and point to encodeCwdForDir for
  full rules
2026-05-05 14:54:20 +03:00
Ed Zynda cbd828e190 fix(session): strip illegal characters from windows session dir (#18)
- Encode cwd via new encodeCwdForDir helper that handles both `/`
  and `\` separators and strips characters illegal in Windows
  directory names (`: < > " | ? *`)
- Fixes session creation on Windows where the drive-letter colon
  produced names like `C:--test` and caused mkdir to fail
- Add regression tests covering Unix paths, Windows drive roots,
  secondary drives, mixed separators, and other illegal chars

Fixes #18
2026-05-05 14:46:36 +03:00
Ed Zynda d304805106 Merge pull request #22 from mark3labs/feat/21-mcp-tasks-mvp
feat(mcp): add MCP Tasks support at the SDK level (#21)
2026-05-04 19:30:15 +03:00
Ed Zynda 6e36053856 fix(mcp): validate tasksMode and inherit task options in Subagent (#21)
Address two review findings on the MCP Tasks PR.

- Config.Validate() now rejects unknown tasksMode values with a clear
  error naming the server and bad value. Without this a typo (e.g.
  "alwasy") was silently downgraded to "auto" by the runtime parser.
- Kit.Subagent() now propagates the parent's six MCP task options
  (mode map, timeout, TTL, poll interval, max poll interval, progress
  callback) onto the child via a new inheritMCPTaskOptions helper.
  Without this, child subagents always saw default polling and no
  progress feedback regardless of parent configuration.

The propagation logic lives in a helper so the test exercises the real
code path instead of duplicating it; future task fields only need to be
added in one place.
2026-05-04 17:06:11 +03:00
Ed Zynda 92eaaf6a59 docs(mcp): document MCP Tasks support (#21)
- README: add tasksMode YAML example and MCP Tasks subsection with
  SDK opt-in snippet
- pkg/kit/README: add MCP Tasks subsection covering MCPTaskMode,
  progress callbacks, and List/Get/Cancel methods
- www/configuration: document the tasksMode server field plus a
  per-mode behaviour table
- www/sdk/options: extend the Compaction & MCP table with the six
  new Options fields and add a top-level MCP Tasks section
- www/sdk/overview: add a brief MCP Tasks section between MCP
  prompts/resources and Context & compaction

All examples verified against the public symbols in pkg/kit/mcp_tasks.go;
docs site builds cleanly via npx tome build.
2026-05-04 17:01:47 +03:00
Ed Zynda e6084b7bd0 feat(mcp): add MCP Tasks support at the SDK level (#21)
Implement Phase 1 of the MCP Tasks spec so long-running tools/call
requests can run asynchronously, survive proxy timeouts, and be
cancelled mid-flight.

- connection pool now advertises mcp.NewTasksCapability() during
  initialize and captures the InitializeResult so callers can detect
  per-server task support
- new MCPServerConfig.TasksMode (auto|never|always, default auto)
  parsed from both new and legacy mcp.json shapes
- ExecuteTool augments tools/call with TaskParams when policy and
  capability allow, polls tasks/get / tasks/result until terminal,
  and best-effort tasks/cancel on context cancellation
- new MCPToolManager methods: SetTaskConfig, ListServerTasks,
  GetServerTask, CancelServerTask
- public SDK surface in pkg/kit: MCPTask, MCPTaskStatus, MCPTaskMode,
  MCPTaskProgress, MCPTaskProgressHandler, plus Options fields
  (MCPTaskMode, MCPTaskTimeout, MCPTaskTTL, MCPTaskPollInterval,
  MCPTaskMaxPollInterval, MCPTaskProgress) and Kit.{List,Get,Cancel}
  MCPTask methods
- works around two upstream mcp-go v0.51.0 parser bugs
  (ParseCallToolResult rejects task responses; ParseTaskResultResult
  looks for content under a non-existent nested key) by decoding the
  wire shape directly via the transport
- defaults to MCPTaskModeAuto so servers that don't advertise task
  support behave exactly as before

Fixes #21
2026-05-04 16:51:09 +03:00
Ed Zynda 34d5abff9c build(deps): update dependencies and implement new acp.Agent methods
- Bump fantasy v0.21.0 -> v0.23.0, mcp-go v0.49.0 -> v0.51.0,
  acp-go-sdk v0.12.0 -> v0.12.2, chroma v2.23.1 -> v2.24.1,
  fsnotify v1.9.0 -> v1.10.1, ultraviolet, AWS SDK, Google API
- Implement CloseSession and ResumeSession on acpserver.Agent to
  satisfy the expanded acp.Agent interface in acp-go-sdk v0.12.2
- Add sessionRegistry.remove helper to support session close
2026-05-04 16:23:12 +03:00
Ed Zynda fc0ddd5f4f update 2026-05-04 15:51:00 +03:00
Ed Zynda 7aa6160c75 updates 2026-05-04 12:10:46 +03:00
Ed Zynda e830bf87ca refactor(models): remove responses API model registration hack
Fantasy v0.21.0 natively includes gpt-5.5 and other newer models in
its responsesModelIDs/responsesReasoningModelIDs lists, making our
workaround unnecessary.

- Delete responses_models.go (go:linkname hack + RegisterResponsesModels)
- Delete responses_models_test.go
- Replace isResponsesAPIModel/isResponsesReasoningModel heuristics with
  direct openai.IsResponsesModel/openai.IsResponsesReasoningModel calls
- Remove RegisterResponsesModels calls from registry init/reload
- Remove hack documentation from AGENTS.md
- Update all deps (fantasy v0.21.0, smithy-go, ultraviolet, etc.)
2026-04-27 09:42:52 +03:00
Ed Zynda 3881d1c28f fix(models): auto-register new OpenAI models for Responses API routing
Fantasy's hardcoded responsesModelIDs list gates whether a model uses
the Responses API or Chat Completions code path. When a new model
(e.g. gpt-5.5) is added via `kit update-models` but fantasy hasn't
been updated yet, the type mismatch between *ResponsesProviderOptions
and *ProviderOptions causes a crash.

- Add isResponsesAPIModel()/isResponsesReasoningModel() helpers that
  supplement fantasy's checks with prefix-based heuristics for modern
  OpenAI model families (gpt-4.1+, gpt-5+, o-series, codex, chatgpt)
- Add RegisterResponsesModels() using go:linkname to append missing
  model IDs from our database into fantasy's internal slices at init
  time and after ReloadGlobalRegistry()
- Replace all direct openai.IsResponsesModel/IsResponsesReasoningModel
  calls in providers.go with the new helpers
- Merge embedded + cached model databases instead of cache-only fallback
- Bump fantasy v0.19.0 -> v0.20.0 to match existing import usage
- Document the technique and model-family update process in AGENTS.md
2026-04-24 15:13:38 +03:00
Ed Zynda 53f6682bd0 refactor(core): remove redundant single-edit mode from edit tool
- Remove top-level old_text/new_text params from edit tool schema
- Make edits array the sole interface; single edits pass 1-item array
- Simplify normalizeEditInput, removing dual-mode branching logic
- Update UI renderer to only read from edits array
- Remove old_text/new_text from bodyKeys in message summarizer
- Update web session HTML to iterate edits array
- Convert all single-edit tests to use Edits array
- Replace mixed-mode test with empty-array validation test
2026-04-23 16:33:55 +03:00
Ed Zynda 996b15c9b9 fix(extensions): return nil error for blocked/disabled tools so LLM sees the reason
Tool blocking via OnToolCall and SetActiveTools returned both a
ToolResponse (IsError=true) and a Go error. Fantasy treats a non-nil
Go error from tool.Run() as a critical failure, aborting the agent
loop without delivering the tool result to the LLM. The model never
saw the block reason and would retry or hallucinate.

- Return nil error for blocked tools (OnToolCall Block=true)
- Return nil error for disabled tools (SetActiveTools)
- Return nil error for extension tool execution failures
- Update tests to assert nil error (IsError response conveys the error)

Fixes #20
2026-04-23 13:13:28 +03:00
Ed Zynda aeb704367c feat(app): update token counts and context fill after every step
- Set context tokens per-step in recordStepUsage instead of waiting
  for turn completion; each step re-sends the full conversation so
  the reported usage monotonically increases
- Add UsageUpdatedEvent to trigger a TUI re-render after each step
  so the status bar reflects updated tokens, cost, and context %
  even during gaps between streaming chunks
- Update test to expect per-step context token updates
2026-04-23 12:56:00 +03:00
Ed Zynda d2e23295b6 perf(ui): cache item heights in ScrollList to eliminate redundant renders
- Add heightCache map to ScrollList, keyed by item ID, avoiding
  repeated Render() calls purely to count lines
- Rewrite GotoBottom() to walk backwards from the end in O(visible)
  instead of two full O(N) forward passes over all items
- Replace all height-only Render() calls in clampOffset(), AtBottom(),
  ScrollBy(), and ScrollPercent() with cached itemHeight() lookups
- Invalidate cache on width changes (SetWidth) and item mutations
  (AppendChunk, AppendStdout/Stderr via InvalidateItemHeight)
- Refresh cache entries in View() from authoritative renders
2026-04-23 12:03:44 +03:00
Ed Zynda e5a13e2e12 feat(sdk): add missing LLM type aliases and remove fantasy dependency leakage
- Add LLMToolResultOutputContentMedia alias (closes gap in tool result types)
- Add LLMToolResultContentType enum and constants (Text, Error, Media)
- Add LLMToolInfo, LLMProviderOptions, LLMProviderMetadata, LLMPrompt aliases
- Replace all fantasy.* references in hooks.go and hooks_test.go with
  SDK-owned aliases, removing the charm.land/fantasy import from both
- Fix gofmt alignment in internal/extensions/symbols.go
- Update SDK skill doc with complete LLM type reference
2026-04-22 21:05:04 +03:00
Ed Zynda 558fb5214f feat(sdk): expose remaining Fantasy lifecycle callbacks as events and hooks
Closes #19.

SDK events (pkg/kit):
- Add 10 new event types: StepStart, StepFinish, TextStart, TextEnd,
  ReasoningStart, Warnings, Source, StreamFinish, Error, Retry
- Add typed convenience subscribers for all 31 event types (20 previously
  required raw Subscribe + type assertion)
- Add OnPrepareStep hook for intercepting/replacing messages between
  steps within a multi-step turn (composes with existing steering)
- Rename OnStreaming to OnMessageUpdate (deprecated alias kept)

Agent internals (internal/agent):
- Add GenerateCallbacks struct replacing 16 positional callback params
- Add GenerateWithCallbacks method; deprecate GenerateWithLoopAndStreaming
- Wire all Fantasy stream callbacks: OnStepStart, OnTextStart/End,
  OnReasoningStart, OnWarnings, OnSource, OnStreamFinish, OnError,
  OnRetry, OnStepFinish (unified step event)
- Compose PrepareStep with steering channel + consumer hook

Extension system (internal/extensions):
- Add 8 new extension events: StepStart, StepFinish, ReasoningStart,
  Warnings, Source, Error, Retry, PrepareStep
- Bridge SDK events to extension runner with Yaegi-safe types (string
  errors, plain int64 token fields, ContextMessage for PrepareStep)

Docs: update README, SDK skill, www/sdk/callbacks, www/sdk/overview
2026-04-22 20:25:06 +03:00
Ed Zynda 61408ed490 fix(sdk): infer ToolResponse.Type for binary data in NewTool/NewParallelTool
- Infer Type="image" for image/* MIME types and Type="media" for all
  other binary content so the downstream framework creates a media
  content block instead of silently discarding Data bytes (#17)
- Extract shared toolOutputToResponse() helper to eliminate duplication
- Add ImageResult() and MediaResult() convenience constructors
- Add LLMToolCall and LLMToolResponse type aliases so SDK consumers
  can call Tool.Run() without importing the underlying framework
- Add 6 regression tests covering image, media, and text responses

Closes #17
2026-04-22 16:58:07 +03:00
Ed Zynda 3cfb6437f9 perf(session,ui): reduce syscalls, allocations, and subprocess spam
- Buffer session JSONL writes with bufio.Writer, flush at sync points;
  ForkToNewSession and AddLLMMessages now batch N entries into ~1 syscall
- Cache lipgloss styles in style.CachedStyles, lazily built and
  invalidated on SetTheme; eliminates ~15 NewStyle() calls per frame in
  hot render paths (reasoning blocks, spinner, tool headers, margins)
- Cache git ls-files results for @file suggestions with 3s TTL; typing
  @filename no longer spawns 3 subprocesses per keystroke
- Use strings.Builder for StreamingMessageItem.content; eliminates O(n²)
  string copying during LLM response streaming
2026-04-22 16:48:17 +03:00
Ed Zynda d33ad4028b fix(kit): enable streaming for subagent child instances
- Set Streaming: true in subagent childOpts to prevent
  viper.Set("stream", false) from polluting global state
- Without this, concurrent subagents and the parent could read
  stale stream=false from viper, causing provider-level issues
  (e.g. Anthropic non-streaming timeouts with extended thinking)
2026-04-22 13:06:37 +03:00
Ed Zynda 307dcd1734 cleanup 2026-04-22 11:56:06 +03:00
Ed Zynda 81240b075e chore: update all deps and fix acp-go-sdk v0.12.0 breaking changes
- Update all Go dependencies (bubbletea v2.0.6, fantasy v0.19.0,
  acp-go-sdk v0.12.0, mcp-go v0.49.0, and transitive deps)
- Replace SetSessionModel with SetSessionConfigOption to match new
  acp-go-sdk Agent interface (union type with ValueId/Boolean variants)
- Add ListSessions stub returning empty list (new required method)
- Refresh embedded_models.json from models.dev/api.json
- Update ACP smoke test: add initialize handshake, session/list,
  session/set_config_option, session/cancel, and fix update parsing
2026-04-22 11:55:40 +03:00
Ed Zynda 9a662d440c fix(ui): reduce TUI visual noise and improve layout
- remove "You" label and icon from user messages, use borderless content block
- remove input title bar ("Enter your prompt...") and hint line
- increase textarea from 3 to 4 rows with top/bottom margin
- hide input hints permanently for a cleaner UI
- match separator colors (use theme.Border for both startup and input dividers)
- make startup separator full terminal width instead of hardcoded 80
- add /help for help hint and pipe separators to status bar
- add printCustomMessage/RenderCustomMessage for custom alert labels
- render /help output as markdown with "Help" alert label
- add Ctrl+V (paste image) to help message keys section
- fix reasoning text wrapping using ANSI-aware lipgloss.Style.Width
- export HighlightFileTokens for cross-package use
2026-04-22 11:41:09 +03:00
Ed Zynda 4ba9d6fab3 feat(events): mirror Fantasy tool input streaming callbacks as Kit events
- Add ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent to SDK
- Wire Fantasy OnToolInputStart/Delta/End through agent to EventBus
- Add typed convenience subscribers: OnToolCallStart/Delta/End on Kit
- Bridge new events to TUI via ToolCallInputStart/Delta/End app events
- Extend extension system with OnToolCallInputStart/Delta/End handlers
- Add extension event types, API methods, loader wiring, Yaegi symbols
- Update docs: README, SDK skill, extensions skill, www/sdk, www/extensions

Closes #16
2026-04-21 23:28:13 +03:00
Ed Zynda aec0e7cc01 docs: document noOAuth MCP server config field
- Add noOAuth to MCP server fields table in www/pages/configuration.md
- Add pubmed example with noOAuth in README and www config docs
2026-04-21 22:44:27 +03:00
Ed Zynda bac04636bf feat(config): add noOAuth flag to skip OAuth on public MCP servers
- Add NoOAuth field to MCPServerConfig with JSON/YAML support
- Guard OAuth error handling and transport setup with the new flag
- Prevents failed dynamic client registration on servers like PubMed
  that do not support OAuth
2026-04-21 22:24:10 +03:00
Ed Zynda 5f851fd08e fix(ui): require double ctrl+c to quit, matching double-esc pattern
- First ctrl+c clears input and arms quit flag with 3s timeout
- Second ctrl+c within timeout window actually quits
- Show '⚠ Press Ctrl+C again to quit' warning after first press
- Empty input no longer quits immediately on single ctrl+c
- Prompt/overlay states: ctrl+c cancels dialog, re-dispatches to
  main handler for double-press tracking instead of quitting
- Update placeholder, help text, and tests to match new behavior
2026-04-21 22:05:13 +03:00
Ed Zynda f8371836d8 fix(cmd): fix character encoding in OAuth success page
Add charset=utf-8 to Content-Type header and use HTML entity
&#10003; instead of raw Unicode checkmark to prevent garbled
text display in browsers.

Fixes #9
2026-04-21 21:19:51 +03:00
Ed Zynda 74f00244be fix(ui): wrap reasoning blocks to terminal width to prevent clipping
- wrap thinking text in StreamComponent and render.ReasoningBlock
- plumb width through renderer and streaming item paths
- keeps style consistent with user/assistant blocks and avoids cut-off lines
2026-04-21 20:42:53 +03:00
Ed Zynda b5d7fd4f3e update docs 2026-04-21 20:33:32 +03:00
Ed Zynda 5857d40978 cleanup 2026-04-21 20:27:32 +03:00
Ed Zynda 3ff701054a fix(models): add gpt-5.4 reasoning level support with auto-adjustment
Adds 'none' thinking level to support OpenAI gpt-5.4 models which use
'reasoning_effort: none' instead of 'minimal'. Includes validation and
auto-adjustment when switching models with incompatible levels.

- Add ThinkingNone constant mapping to ReasoningEffortNone
- Add IsValidThinkingLevelForModel() with gpt-5.4 detection
- Add SuggestThinkingLevelFallback() for level migration
- Auto-adjust thinking level on model switch with user notification
- Update all docs to include 'none' in valid levels

Fixes #11
2026-04-21 20:19:00 +03:00
Ed Zynda c1dee3ceba feat(cmd): add --set-default flag and improve auth error messages
Add --set-default flag to 'kit auth login' to automatically set the
provider's default model after successful authentication. When no Anthropic
credentials exist but OpenAI credentials are detected, error messages
now suggest using OpenAI with the correct --model flag.

Fixes #9
2026-04-21 19:52:06 +03:00
Ed Zynda 2d9783a44d fix(ui): make ctrl+c clear input before quitting
Change Ctrl+C behavior to match other terminal AI tools (claude, codex, pi):
- First Ctrl+C clears the current input when text is present
- Second Ctrl+C (within 3 seconds) quits the application
- Ctrl+C on empty input quits immediately
- 3-second auto-reset timer clears the 'pressed once' state
- Flag also resets after message submission

Updates placeholder text and help message to reflect new behavior.

Fixes #13
2026-04-21 19:32:48 +03:00
Ed Zynda 88dd216e15 fix(session): prevent circular parent references in tree session
Add defensive validation to detect and prevent cycles in the session tree
parent chain that could occur after compaction or file corruption.

- Add tree_validation.go with cycle detection and parent chain validation
- Validate parent chain before appending messages (AppendMessage)
- Validate firstKeptEntryID exists in AppendCompaction
- Add depth limit and cycle detection to buildTreeNode to prevent infinite recursion
- Log diagnostics on session open to detect existing cycles
- Add tests for cycle detection and graceful handling
2026-04-21 16:24:38 +03:00
Ed Zynda 9e5806ade8 fix(subagent): remove biased model example from tool schema
- Remove vendor-specific model example that could bias LLM selection
- Add minimum recommended timeout guidance to subagent schema
2026-04-21 11:28:32 +03:00
Ed Zynda 50f586ec8f chore(models): update embedded model database from models.dev
Update internal/models/embedded_models.json with the latest snapshot
from https://models.dev/api.json.

- Providers: 111 → 115 (+4)
- Models: 4,191 → 4,259 (+68)
2026-04-21 10:38:23 +03:00
Ed Zynda 8a8e684dff docs(sdk): document MCPAuthHandler and OAuth opt-in behavior
Reflect the refactor that made MCPAuthHandler an explicit, opt-in
dependency for remote MCP OAuth. Four surfaces updated:

- README.md: new 'MCP OAuth (remote MCP servers)' subsection under the
  Go SDK section, outlining the three consumer patterns (nil / CLI /
  custom) and linking to the full options docs.
- pkg/kit/README.md: type cheat-sheet now lists MCPAuthHandler,
  DefaultMCPAuthHandler, and CLIMCPAuthHandler alongside the existing
  MCPTokenStore entries.
- skills/kit-sdk/SKILL.md: Options example annotated with nil-disables-
  OAuth semantics; new 'MCP OAuth Authorization' section precedes the
  existing token-storage section; re-exported types list expanded.
- www/pages/sdk/options.md: Options fields table gains MCPAuthHandler
  row; new top-level 'MCP OAuth Authorization' section with consumer
  matrix, CLI/custom/fully-custom code samples, and a warning callout
  about the OnAuthURL nil-hang footgun.
2026-04-17 15:30:10 +03:00
Ed Zynda 7ef99ac60f refactor(sdk): remove UX policy from MCP OAuth handler
Strip user-facing I/O out of the SDK's OAuth surface so library, daemon,
and web-app embedders are not surprised by port binds or browser opens.

- DefaultMCPAuthHandler no longer calls openBrowser; it exposes an
  OnAuthURL(serverName, authURL) hook and performs no presentation I/O.
- kit.New no longer auto-constructs a default handler when
  Options.MCPAuthHandler is nil. OAuth is opt-in; remote MCP servers
  requiring authorization fail with a clear error if no handler is set.
- CLIMCPAuthHandler owns the CLI policy (browser open + stderr prints)
  by wiring an OnAuthURL closure on the inner DefaultMCPAuthHandler.
- openBrowser is now unexported and colocated with its sole caller; no
  new exported helper is added to the SDK surface.

BREAKING CHANGE: SDK consumers relying on implicit OAuth with a nil
MCPAuthHandler must now pass kit.NewCLIMCPAuthHandler() (or a custom
implementation) explicitly. The kit CLI is unaffected — cmd/root.go
already constructs the handler explicitly.
2026-04-17 15:26:35 +03:00
Ed Zynda a67f514560 chore(models): refresh embedded models.dev database
- update internal/models/embedded_models.json from https://models.dev/api.json
- 110 → 111 providers, 4172 → 4191 models
2026-04-17 12:19:21 +03:00
Ed Zynda b6bb35cb71 Merge pull request #7 from mark3labs/feat/sdk-options-overrides
feat(sdk): expose generation and provider params on Options
2026-04-17 12:15:47 +03:00
Ed Zynda 4e82fac442 fix(fileutil): decouple TestDetectMediaType from system MIME db
TestDetectMediaType/.go fails on CI images (Ubuntu mime-support) where
/etc/mime.types registers '.go → text/x-go', because mime.TypeByExtension
reads those files at init. The test intended to exercise the 'unknown
extension falls through to text/plain' branch but used a real extension,
making the assertion environment-dependent.

Replace '.go' with '.kitsyntheticext', an invented extension that no
system MIME database registers. The fallback path is now exercised
deterministically on any host.
2026-04-17 12:13:28 +03:00
Ed Zynda 5ec2217b0f docs(sdk): document global viper state leakage in New and Options
The SDK applies Options by calling viper.Set on viper's process-global
store, which means two Kits constructed in the same process are not
isolated from each other: the second New overwrites the first's keys,
and downstream readers (SetModel, GetThinkingLevel, BuildProviderConfig)
observe the most recent value.

- Add a 'Global viper state warning' block to the Options godoc
  explaining the leak, the zero-value-does-not-clear gotcha, and
  pointing at viper.Reset() as the migration workaround.
- Add a matching warning to the New godoc so consumers discover the
  constraint from either entry point.
- Detach the viperInitMu godoc (previously lodged inside New's comment
  block) and clarify that the mutex only guards the construction
  window, not instance isolation.
- Add a TODO noting the proper fix: refactor to a per-call viper.New()
  instance so each Kit owns its own config store.
2026-04-17 12:09:13 +03:00
Ed Zynda 8a851723ba style(sdk): gofmt trailing newlines in kit_test.go 2026-04-17 12:07:54 +03:00
Ed Zynda 53b628c5f8 fix(sdk): map hyphenated config keys to KIT_* env vars
- InitConfig now installs a viper env key replacer so keys like
  "max-tokens" bind to KIT_MAX_TOKENS under AutomaticEnv; previously
  hyphenated keys silently missed their documented env overrides.
- Simplify TestNewPreservesIsSetSemantics: with SkipConfig: true no env
  bindings are registered, so the os.Getenv guard and upper() helper
  were dead weight. Remove both and drop the unused helper.
2026-04-17 12:07:29 +03:00
Ed Zynda e1c94cb362 fix(sdk): align SDK max-tokens floor with CLI default (4096 → 8192)
The SDK last-resort MaxTokens floor is applied in kit.New() when
Options.MaxTokens, KIT_MAX_TOKENS, .kit.yml, and per-model defaults
are all unset. It was 4096 (inherited from the old setSDKDefaults
viper default) while the CLI --max-tokens cobra default is 8192.

Bump the floor to 8192 so SDK and CLI callers start from the same
base value before rightSizeMaxTokens runs, then update README,
skills/kit-sdk/SKILL.md, and www/pages/{configuration,sdk/options}.md
to match.
2026-04-17 11:59:49 +03:00
Ed Zynda ecf95b52e1 fix(sdk): preserve IsSet semantics for generation param overrides
Previously setSDKDefaults() registered viper.SetDefault for max-tokens,
temperature, top-p, top-k, frequency/presence-penalty, and thinking-level.
viper.SetDefault makes IsSet() return true, which silently suppressed
per-model defaults (ApplyModelSettings) and automatic right-sizing
(rightSizeMaxTokens) for every SDK-created Kit — and for CLI runs too,
since cmd/root.go routes through kit.New. Effective max-tokens for
claude-sonnet-4-5 was pinned at 4096 instead of 32768.

- Drop SetDefault for all IsSet-sensitive keys; keep only model,
  system-prompt, stream, num-gpu-layers, main-gpu.
- Apply a 4096 max-tokens floor directly on the *models.ProviderConfig
  struct in kit.New() when nothing else resolved a value. Keeps
  viper.IsSet("max-tokens") == false so rightSizeMaxTokens and
  per-model maxTokens overrides still fire.
- Update Options.MaxTokens / ThinkingLevel godoc to describe the real
  precedence chain.
- Strengthen tests: add Temperature subtest; add
  TestNewPreservesIsSetSemantics regression covering all seven keys;
  split TestNewWithProviderOptions into three subtests including
  Options-beats-viper-state and ProviderURL propagation; add
  resetViper helper so subtests don't bleed state.
- Document the new SDK fields (MaxTokens, ThinkingLevel, Temperature,
  TopP, TopK, FrequencyPenalty, PresencePenalty, ProviderAPIKey,
  ProviderURL, TLSSkipVerify) in README, skills/kit-sdk, and the www
  configuration / sdk/options / sdk/overview pages, including a
  dedicated precedence table.
2026-04-17 11:50:45 +03:00
Ed Zynda 0641c92acc feat(sdk): expose generation and provider params on Options
Adds programmatic overrides on kit.Options for the model/provider knobs
that were previously only reachable through viper.Set() — letting SDK
consumers (web apps, services, embedded agents) configure kit fully
in-code without polluting global viper state or shipping .kit.yml.

Generation parameters:
  - MaxTokens         int      (max output tokens per response)
  - ThinkingLevel     string   (off/low/medium/high)
  - Temperature       *float32
  - TopP              *float32
  - TopK              *int32
  - FrequencyPenalty  *float32
  - PresencePenalty   *float32

Sampling params use pointer types so explicit 0 is distinguishable from
unset; nil leaves provider/per-model defaults in place.

Provider configuration:
  - ProviderAPIKey    string
  - ProviderURL       string
  - TLSSkipVerify     bool

Implementation just pushes Options values into viper inside New(),
so all existing downstream code (BuildProviderConfig, SetModel,
modelSettings lookups, runtime model switching) picks them up
uniformly without any new code paths. Tests added for MaxTokens,
ThinkingLevel, and ProviderAPIKey.
2026-04-17 11:24:00 +03:00
Ed Zynda 3bb20f5283 feat(models): surface and prevent silent max-tokens truncation
- Raise --max-tokens default from 4096 to 8192.
- Auto-raise MaxTokens toward the model's catalog Limit.Output (capped at
  32768) when the user hasn't set --max-tokens explicitly and no per-model
  modelSettings override applied. Prevents silent 4k/8k truncation on
  models that support 32k-262k output.
- Surface FinishReasonLength at turn end: the app now subscribes to
  TurnEndEvent and renders a system-message banner explaining the current
  cap, the model's known ceiling, and how to raise it. Previously the TUI
  swallowed 'length' stops, producing 'ghost' truncations.
- Export FinishReason* constants on pkg/kit (Stop, Length, ToolCalls,
  ContentFilter, Error, Other, Unknown) and fix stale comments that used
  Anthropic-style strings.
- Add Kit.MaxTokens() and Kit.MaxOutputLimit() SDK accessors, backed by
  Agent.GetMaxTokens() which correctly returns 0 for providers that
  suppress the param (e.g. Codex OAuth).
- Tests: rightSizeMaxTokens covers 7 paths (cap, raise, preserve,
  explicit flag, nil info, zero limit); handleTurnEnd covers length/
  non-length/nil-sendFn and the fallback message formatter.
- Docs: update configuration.md, cli/flags.md, and kit-extensions skill
  to reflect the new default and behavior.
2026-04-16 23:12:10 +03:00
Ed Zynda 633fa38b2b fix(ui): regenerate spinner frames on theme change
- UpdateTheme() only refreshed typography styles, leaving spinner
  frames rendered with the old theme's colors
- Now calls knightRiderFrames() to rebuild frames with the new
  theme's Primary, Muted, VeryMuted, and MutedBorder colors
2026-04-16 12:32:49 +03:00
Ed Zynda f905cee48c fix(ui): dynamically size slash command name column in popup
- Replace hardcoded nameWidth of 15 with dynamic calculation based on
  the longest command name in the filtered list
- Prevents truncation of longer names like /feature-request and
  /release-tagger that were cut off with ellipsis
- Cap name column to leave at least 20 chars for descriptions
- Add 1 char gap between name and description columns
2026-04-16 12:27:56 +03:00
Ed Zynda 182c10ea1a refactor(ui): improve keybinding ergonomics for terminal multiplexers
- Move thinking toggle from ctrl+t to leader chord (ctrl+x t) to avoid
  conflicts with tmux/zellij tab mode and terminal new-tab shortcuts
- Change scrollback jump from alt+home/alt+end to ctrl+home/ctrl+end
  for better compatibility across SSH and older tmux versions
- Remove ctrl+d as submit alias (enter suffices); avoids EOF convention
  confusion and accidental shell disconnects
- Remove ctrl+a from tree selector filter shortcuts to avoid conflict
  with the common tmux prefix remap (ctrl+o cycle still reaches all
  filter modes)
2026-04-16 12:21:37 +03:00
Ed Zynda fcaa52bf1c fix(extensions): serialize handler calls per-extension to prevent data races
- Add per-extension reentrant mutex to Runner that serializes handler
  invocations from concurrent goroutines (e.g. parallel subagent events)
  while allowing re-entrant calls (handler → EmitCustomEvent → handler)
- Fix subagent-monitor slice aliasing bug: submonEntries[:0] reuses the
  backing array, corrupting entries during in-place filtering
- Pass parent's loaded MCPConfig to child subagents in Kit.Subagent(),
  eliminating concurrent viper map access during parallel kit.New() calls
- Add Options.MCPConfig field so SDK consumers can also skip viper reads
- Add tests for concurrent emit, cross-extension concurrency, and
  re-entrant EmitCustomEvent
2026-04-16 12:11:10 +03:00
Ed Zynda 7e6455732c docs: update documentation for sudo password prompt feature
- README.md: mention interactive sudo password prompt in features
- skills/kit-sdk/SKILL.md: add PasswordPromptEvent to event types table
- www/pages/index.md: update features list with sudo prompt
- www/pages/development.md: update project structure description
- www/pages/sdk/callbacks.md: add complete event types table
2026-04-15 18:06:11 +03:00
181 changed files with 19211 additions and 5761 deletions
-268
View File
@@ -1,268 +0,0 @@
//go:build ignore
package main
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"kit/ext"
)
const (
diagnosticsTimeout = 20 * time.Second
maxOutputBytes = 12_000
)
type toolPathInput struct {
Path string `json:"path"`
}
type lintResult struct {
Output string
Err error
}
// Package-level state: set of .go files edited during the current agent turn.
var editedFiles map[string]bool
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint after agent turns that edit Go files")
})
// Track edited .go files — don't lint yet.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
if e.IsError || !isEditOrWrite(e.ToolName) {
return nil
}
absPath, ok := resolveGoFilePath(e.Input, ctx.CWD)
if !ok {
return nil
}
if editedFiles == nil {
editedFiles = make(map[string]bool)
}
editedFiles[absPath] = true
return nil
})
// After the agent turn ends, lint all collected files.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if len(editedFiles) == 0 {
return
}
// Snapshot and reset immediately so the next turn starts clean.
files := editedFiles
editedFiles = nil
// Skip lint on errored turns.
if e.StopReason == "error" {
return
}
// Collect unique directories and file list for gopls.
var allGoplsOutput []string
for absPath := range files {
res := runGopls(ctx.CWD, absPath)
formatted := formatToolResult(res, "")
if formatted != "" {
allGoplsOutput = append(allGoplsOutput, fmt.Sprintf("# %s\n%s", filepath.Base(absPath), formatted))
}
}
lintRes := runGolangCILint(ctx.CWD, "./...")
goplsSection := "No diagnostics."
if len(allGoplsOutput) > 0 {
goplsSection = strings.Join(allGoplsOutput, "\n\n")
}
lintSection := formatToolResult(lintRes, "No lint issues.")
// Build file list for the report header.
var fileNames []string
for absPath := range files {
fileNames = append(fileNames, filepath.Base(absPath))
}
report := fmt.Sprintf(
"<go_diagnostics files=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
strings.Join(fileNames, ", "),
goplsSection,
lintSection,
)
goplsIssues, lintIssues := countIssues(report)
hasIssues := goplsIssues > 0 || lintIssues > 0
if hasIssues {
// Show TUI block so the user sees it too.
var msgLines []string
msgLines = append(msgLines, fmt.Sprintf("Files: %s", strings.Join(fileNames, ", ")))
if goplsIssues > 0 {
msgLines = append(msgLines, fmt.Sprintf("gopls: %d issue(s)", goplsIssues))
}
if lintIssues > 0 {
msgLines = append(msgLines, fmt.Sprintf("golangci-lint: %d issue(s)", lintIssues))
}
borderColor := "#f9e2af" // yellow
if goplsIssues > 0 && lintIssues > 0 {
borderColor = "#f38ba8" // red
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: strings.Join(msgLines, "\n"),
BorderColor: borderColor,
Subtitle: "go-edit-lint",
})
// Inject a follow-up message so the agent fixes the issues.
ctx.SendMessage(report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review and fix the issues above.")
} else {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: fmt.Sprintf("Files: %s\n✓ All clean", strings.Join(fileNames, ", ")),
BorderColor: "#a6e3a1",
Subtitle: "go-edit-lint",
})
}
})
}
func isEditOrWrite(toolName string) bool {
return strings.EqualFold(toolName, "edit") || strings.EqualFold(toolName, "write")
}
func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
var args toolPathInput
if err := json.Unmarshal([]byte(inputJSON), &args); err != nil || args.Path == "" {
return "", false
}
absPath := args.Path
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(cwd, absPath)
}
if strings.ToLower(filepath.Ext(absPath)) != ".go" {
return "", false
}
return absPath, true
}
func runGopls(cwd, absPath string) lintResult {
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "gopls", "check", absPath)
cmd.Dir = cwd
out, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
}
if err != nil {
return lintResult{Output: truncate(string(out), maxOutputBytes), Err: fmt.Errorf("failed to run gopls check: %w", err)}
}
return lintResult{Output: truncate(string(out), maxOutputBytes)}
}
func runGolangCILint(cwd, target string) lintResult {
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
defer cancel()
args := []string{
"run",
target,
"--show-stats=false",
"--output.text.path", "stdout",
"--output.text.colors=false",
"--output.text.print-issued-lines=false",
}
cmd := exec.CommandContext(ctx, "golangci-lint", args...)
cmd.Dir = cwd
out, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
}
trimmed := truncate(string(out), maxOutputBytes)
if err == nil {
return lintResult{Output: trimmed}
}
exitErr, ok := err.(*exec.ExitError)
if ok && exitErr.ExitCode() == 1 {
return lintResult{Output: trimmed}
}
return lintResult{Output: trimmed, Err: fmt.Errorf("failed to run golangci-lint: %w", err)}
}
func formatToolResult(res lintResult, emptyFallback string) string {
var lines []string
if res.Err != nil {
lines = append(lines, "ERROR: "+res.Err.Error())
}
out := strings.TrimSpace(res.Output)
if out == "" {
if res.Err == nil {
if emptyFallback != "" {
lines = append(lines, emptyFallback)
}
}
} else {
lines = append(lines, out)
}
if len(lines) == 0 {
return emptyFallback
}
return strings.Join(lines, "\n")
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n... output truncated ..."
}
func countIssues(report string) (goplsCount, lintCount int) {
goplsStart := strings.Index(report, "[gopls]")
lintStart := strings.Index(report, "[golangci-lint]")
endTag := strings.Index(report, "</go_diagnostics>")
if goplsStart != -1 && lintStart != -1 {
goplsSection := report[goplsStart:lintStart]
for _, line := range strings.Split(goplsSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[gopls]" && line != "No diagnostics." && !strings.HasPrefix(line, "#") {
goplsCount++
}
}
}
if lintStart != -1 && endTag != -1 {
lintSection := report[lintStart:endTag]
for _, line := range strings.Split(lintSection, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
lintCount++
}
}
}
return goplsCount, lintCount
}
+146
View File
@@ -0,0 +1,146 @@
---
description: Read-only audit for dead code, duplication, boundary violations, and refactor opportunities
---
Perform a comprehensive **read-only** audit of this repository and report
findings. **Do not edit, rename, or delete any files.** Optional focus / scope
hints from the user: $@
## Scope
If the user supplied focus hints above (a package path, a subsystem name, a
concern like "TUI" or "extensions"), scope the audit accordingly. Otherwise
audit the whole repo, prioritising the highest-traffic packages first
(`cmd/`, `internal/`, `pkg/kit/` for this repo).
## Steps
1. **Map the repo first**:
- `ls` / `find` the top-level layout and list every Go package
- Read `AGENTS.md`, `README.md`, and any `pkg/*/doc.go` to understand the
intended architectural boundaries (SDK vs internal vs TUI vs cmd vs
extension surface)
- Note the public SDK surface (`pkg/kit/`) and any documented invariants
(e.g. "no dependency name leakage", "UI never imports extensions
directly") — these define what counts as a violation
2. **Hunt for dead code**:
- Run `go vet ./...` and capture warnings
- Use `grep` to find exported symbols (`^func [A-Z]`, `^type [A-Z]`,
`^var [A-Z]`, `^const [A-Z]`) and cross-reference call sites. Symbols
with zero non-test references inside the module are suspects
- Check for unreferenced files, `// TODO: remove` markers, commented-out
blocks, and `_ = x` discard patterns
- If `staticcheck`, `deadcode`, or `unused` are available on PATH, run
them and include their output verbatim
- **Do not delete anything** — list candidates with file:line and a
confidence level (high / medium / low)
3. **Find unnecessary duplication**:
- Look for near-identical function bodies, struct shapes, or switch
statements across packages — `grep` for repeated function signatures
and copy-pasted string literals / error messages is a fast first pass
- Distinguish *coincidental* duplication (two things that happen to look
alike but evolve independently) from *unnecessary* duplication (same
intent, drifting in lockstep) — only flag the latter
- For each cluster, propose where the extracted helper should live
(which package, which file) and whether it crosses a boundary
4. **Check concerns / boundary violations**:
- **SDK leakage**: grep `pkg/kit/` for imports of `internal/...` types
in exported signatures, and for dependency-name leakage in exported
names / godoc (e.g. library jargon appearing in `LLM*` types)
- **UI ↔ extensions**: grep `internal/ui/` for any import of
`internal/extensions/` — per AGENTS.md the UI must not import
extensions directly; converters in `cmd/root.go` should bridge them
- **cmd vs internal**: business logic living in `cmd/` that should be
in `internal/` (and vice versa)
- **Cyclic risk**: packages that import each other transitively or that
reach across sibling boundaries unexpectedly
- For each violation, cite the offending import / signature with
file:line
5. **Spot refactor opportunities**:
- Long functions (>80 lines) doing multiple unrelated things
- Deeply nested conditionals that flatten well with early returns
- Repeated `if err != nil { return fmt.Errorf("...: %w", err) }` chains
that could become helpers — but only where the wrapping context is
genuinely uniform
- Structs with too many fields that hint at split responsibilities
- Exported APIs that would be cleaner with options structs / functional
options
- Tests that share setup boilerplate ripe for a helper
- Flag each with: location, current shape (1-2 lines), proposed shape
(1-2 lines), and estimated risk (low / medium / high)
6. **Cross-check against project rules**:
- Re-read `AGENTS.md` "Key Patterns" section and verify nothing in your
findings contradicts the documented gotchas (Yaegi interface ban,
`prog.Send()` from `Update()`, function-field bug, etc.) — if a
"refactor" would reintroduce a known pitfall, drop it from the report
and note why
7. **Write the report** as your final message (do not write it to disk)
structured as:
```
# Code Audit Report
## Summary
- N dead-code candidates
- N duplication clusters
- N boundary violations
- N refactor opportunities
## Dead Code
### High confidence
- path/to/file.go:LINE — symbol — reason
### Medium confidence
...
## Duplication
### Cluster: <short name>
- Sites: file:line, file:line, …
- Suggested home: package/path
- Notes: …
## Boundary Violations
- Rule: <which rule from AGENTS.md / project convention>
- Offender: file:line
- Fix sketch: …
## Refactor Opportunities
- Location: file:line
- Current: …
- Proposed: …
- Risk: low/medium/high
- Why it's worth it: …
## Suggested Next Steps
1. …
2. …
```
8. **End the report with an explicit reminder** that no files were modified,
and recommend the user pick the highest-leverage items to act on
manually (or via a follow-up `/fix-issue` style prompt) rather than
running a sweeping refactor.
## Guidelines
- **Read-only, always**: no `edit`, no `write`, no `git commit`, no `go mod
tidy`. Use only `read`, `grep`, `find`, `ls`, and read-only `bash`
commands (`go vet`, `go build -o /tmp/...`, `staticcheck`, etc.)
- **Cite every finding** with `path/to/file.go:LINE` so the user can jump
straight to it
- **Be honest about confidence**: false positives in a code audit are
expensive — prefer "medium confidence, worth a look" over confidently
wrong claims
- **Quantity isn't quality**: 10 sharp findings beat 100 nitpicks. Cut
anything that's purely stylistic unless it directly causes one of the
four issue categories above
- **Skip generated code** (`*.pb.go`, `*_gen.go`, anything under
`vendor/`) and obvious third-party copies
- **Don't propose architectural rewrites** — stay within the existing
shape of the repo and recommend incremental, reviewable changes
+47
View File
@@ -0,0 +1,47 @@
---
description: Open a GitHub PR for the current branch using the repo's PR template
---
Open a GitHub pull request for the current branch, filling out the repository's PR template with a description grounded in the actual commits and diff.
## Steps
1. **Verify the branch is pushed**:
- `git status -sb` and `git log @{u}..HEAD --oneline 2>/dev/null` — if there is no upstream or unpushed commits, run `git push -u origin "$(git branch --show-current)"` first
- If the working tree is dirty, stop and tell the user to commit first (suggest `/commit-push`)
2. **Gather context**:
- `git log origin/main..HEAD --oneline` — list of commits going into the PR
- `git diff origin/main...HEAD --stat` then `git diff origin/main...HEAD` — read the actual changes
- Identify the linked issue (from commit messages, branch name, or extra user input: $@) — capture as `Fixes #N` if applicable
3. **Locate the PR template**:
- Check `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, or `docs/pull_request_template.md`
- If none exists, use a minimal `## Description` / `## Type of Change` / `## Checklist` structure
4. **Draft the PR body** by filling out the template:
- **Description**: 13 short paragraphs explaining *what* changed and *why*, grounded in the diff. Include a brief before/after example for new APIs when useful.
- **Fixes #N**: only if there is a real linked issue
- **Type of Change**: tick the single most accurate box with `[x]` (leave others as `[ ]`)
- **Checklist**: tick items that are genuinely true (style, self-review, tests added, docs updated)
- **Additional Information**: bullet list of added / modified files and any backward-compatibility notes
- Remove template sections explicitly marked "remove if not applicable" (e.g. MCP Spec Compliance) when they don't apply
5. **Write the body to a temp file**: `/tmp/pr-body-<branch-or-issue>.md` — never inline a long body via `--body`, always use `--body-file`
6. **Choose the title**: prefer the subject of the primary commit if it already follows Conventional Commits; otherwise craft one in the same style (`<type>(<scope>): <imperative summary>`, ≤72 chars)
7. **Create the PR**:
```
gh pr create \
--title "<title>" \
--body-file /tmp/pr-body-<...>.md \
--base main \
--head "$(git branch --show-current)"
```
Use the repo's actual default branch if it isn't `main` (`gh repo view --json defaultBranchRef -q .defaultBranchRef.name`)
8. **Report the PR URL** returned by `gh` and stop
## Guidelines
- Read the diff and commit messages — do **not** invent features that aren't in the code
- One PR per logical change; if the branch contains unrelated commits, surface that and ask before continuing
- Keep the description focused on reviewer-relevant information (what / why), not a replay of the diff
- Only check checklist boxes that are actually satisfied; leave the rest unchecked rather than lying
- If `gh` is not authenticated (`gh auth status` fails), stop and tell the user
$@
+2 -2
View File
@@ -2,7 +2,7 @@
description: Create a feature request using the GitHub template
---
Create a feature request for the Kit repository. The user wants to request: $+
Create a feature request for the Kit repository. The user wants to request: $@
## Feature Request Template
@@ -16,7 +16,7 @@ This prompt uses the `feature_request` GitHub template which requires:
## Steps
1. **Understand the request** from `$+`
1. **Understand the request** from the user input: $@
- What capability is missing?
- What would the ideal behavior look like?
+2 -2
View File
@@ -2,7 +2,7 @@
description: File a GitHub issue using the appropriate template
---
File a GitHub issue for the Kit repository. The user wants to create an issue about: $+
File a GitHub issue for the Kit repository. The user wants to create an issue about: $@
## Issue Templates Available
@@ -16,7 +16,7 @@ This repository has structured issue templates. You MUST use the appropriate tem
## Steps
1. **Determine the issue type** from `$+`:
1. **Determine the issue type** from the user input: $@
- Bug → use `--template bug_report`
- Feature → use `--template feature_request`
- Documentation → use `--template documentation`
+61
View File
@@ -0,0 +1,61 @@
---
description: Implement the fix/feature/docs change requested by a GitHub issue
---
Resolve GitHub issue #$1 by reading it, classifying it, and producing the appropriate code or doc change. **Stop once the working tree contains the change** — committing, pushing, and opening a PR are handled by `/commit-push` and `/create-pr`.
## Steps
1. **Fetch the issue**:
- Run: gh issue view $1 --json number,title,body,labels,state,author,comments
- If the issue is closed, stop and ask the user whether to proceed
- Read the **entire** thread including comments — the latest comment often refines the ask
2. **Classify the issue** from labels, title prefix, and body content:
- `bug` / `fix:` → reproduce, then fix
- `enhancement` / `feature` / `feat:` → design, then implement
- `documentation` / `docs:` → locate and update docs
- `question` / `discussion` → answer in a comment, do **not** write code
- Anything else → ask the user how to proceed
3. **Create a working branch** off the default branch:
- `git checkout main && git pull --ff-only`
- Branch name: <type>/$1-<slug> (e.g. `fix/42-borderColor-ignored`, `feat/57-keyboard-clear`, `docs/63-widget-lifecycle`)
4. **Do the work** based on type:
### Bug (`bug` label / `fix:` title)
- Reproduce the failure first (write a failing test if feasible) — if you cannot reproduce, comment on the issue asking for clarification and stop
- Locate the root cause; do not patch symptoms
- Add or extend a regression test that fails before and passes after the fix
- Run `go test ./... -race` and `golangci-lint run`
### Feature (`enhancement` / `feature` label / `feat:` title)
- Re-read the motivation and proposed implementation in the issue body
- For large, ambiguous, or breaking changes, sketch the design in a comment on the issue and wait for sign-off before writing code
- Implement behind sensible defaults; add godoc on every exported symbol
- Add unit tests covering the new behaviour and edge cases
- Update `README.md` / `docs/` if the public surface changed
- Run `go test ./... -race` and `golangci-lint run`
### Documentation (`documentation` label / `docs:` title)
- Open the file/URL referenced in the issue's "Documentation Location"
- Apply the suggested improvement; verify code samples compile (`go build ./...`)
- No tests required, but run `golangci-lint run` if Go files were touched
5. **Report**:
- Branch name (`git branch --show-current`)
- Summary of files changed (`git status -s`) and the diff highlights
- Test/lint results (pass/fail with key output)
- Suggest the next step explicitly:
- `/commit-push` to commit with a Conventional Commit subject (the message should reference `(#$1)` and include `Fixes #$1` so merge auto-closes)
- then `/create-pr $1` to open the pull request
## Guidelines
- This prompt **stops at a clean working tree with the change applied** — do not run `git commit`, `git push`, or `gh pr create`
- If the issue is unclear, post a clarifying comment on the issue and stop; do not guess
- Keep the change scoped to the issue; surface unrelated cleanups separately
- For breaking changes or architecture shifts, propose the design on the issue first and wait for maintainer sign-off
- If the issue is a duplicate or already fixed on `main`, comment with the reference and stop
- Do not close the issue manually — the eventual PR's `Fixes #$1` handles that on merge
+48 -13
View File
@@ -2,7 +2,7 @@
description: Scaffold a new prompt template in .kit/prompts/
---
Create a new kit prompt template. The user wants a prompt that does: $+
Create a new kit prompt template. The user wants a prompt that does: $@
## What a prompt template is
@@ -16,30 +16,64 @@ It becomes a `/slug` slash command in the kit input box — typed as `/filename`
description: One-line description shown in autocomplete
---
Body text of the prompt. Use $@ for all user-supplied arguments,
$1 $2 etc. for positional arguments.
Body text of the prompt. Reference user-supplied arguments
with positional placeholders (see "Argument placeholders" below).
```
- **Filename** → slug: `commit-push.md` becomes `/commit-push`
- **Frontmatter**: only `description` is recognised; keep it under ~80 chars
- **Body**: plain markdown; the full text is submitted as the user's message when the template fires
- **Arguments**: `$+` expands to everything the user typed after the slash command name
(requires at least one argument); `$@` is the same but allows zero arguments;
`$1`, `$2` for individual positional args; omit entirely if no arguments are needed
- **Required args**: kit infers required positional args from the highest `$N` it finds *outside* backtick/tilde code fences — a stray `$2` in active prose means kit will refuse to run without 2 arguments
## Argument placeholders
kit performs shell-style substitution before sending the prompt to the model:
- `$1`, `$2`, … — positional arguments (1-indexed)
- `${1}`, `${2}`, … — same, brace form (use when followed by digits/letters: `${1}_suffix`)
- `$@` — all arguments joined by spaces (zero or more, optional)
- `$+` — all arguments, **at least one required**
- `$ARGUMENTS` / `${ARGUMENTS}` — alias for `$@`
- `${@:N}` — args from the Nth onwards (1-indexed, bash-style)
- `${@:N:L}``L` args starting from the Nth
### ⚠️ Critical: code fences and inline code preserve placeholders verbatim
Anything inside triple-backtick fences, `~~~` fences, or single-backtick `inline` code spans is **left untouched** so example code samples don't get corrupted. That means:
- An inline-coded `gh issue view $1` stays literal `$1` in the model's input ❌
- The same command without backticks: gh issue view $1 → expands to `gh issue view 42`
**Rule of thumb:** if you want a placeholder to substitute, keep it outside backticks and fences. If you want a literal `$1` in the output (e.g. teaching the user shell syntax), put it inside backticks.
### Workarounds for "I want it to look like code AND substitute"
1. **Drop the backticks** around just the placeholder portion — the rest can still read as a command line in prose
2. **Use a 4-space-indented code block** instead of a triple-backtick fence — kit only skips backtick/tilde fences, so indentation-style code blocks still get substitution:
git push -u origin "$(git branch --show-current)"
gh pr create --title "fix: ... (#$1)" --base main
3. **Bind once, reference loosely**: put `Issue: $1` at the top in prose, then leave the backticked examples literal — the model will substitute mentally
## Steps
1. **Understand the workflow** the user described in `$+` — ask a clarifying question if the intent is ambiguous
1. **Understand the workflow** the user described in $@ — ask a clarifying question if the intent is ambiguous
2. **Choose a filename**: short, lowercase, hyphen-separated, descriptive (e.g. `code-review.md`)
3. **Write the description**: one sentence, imperative, fits in autocomplete
4. **Draft the body**:
- Open with a single sentence stating the goal
4. **Decide on arguments**:
- No args needed → omit placeholders entirely
- One required value (issue number, PR url, file path) → use `$1`
- Free-form trailing context → end with a single `$@` line
- Multiple distinct values → use `$1`, `$2`, … and document each at the top
5. **Draft the body**:
- Open with a single sentence stating the goal, weaving in `$1`/`$@` where the value belongs
- Use `## Steps` for multi-step workflows; use plain prose for simple prompts
- Be specific: name commands, flags, and file paths where relevant
- End with `$+` on its own line if the user must pass context; use `$@` if arguments
are optional; omit if the prompt is self-contained
5. **Write the file** to `.kit/prompts/<slug>.md`
6. **Confirm** by showing the final file content and the slash command that activates it
- **Audit every backtick and code fence**: any `$N` or `$@` inside them will not expand — was that intentional? If not, apply one of the workarounds above
6. **Write the file** to `.kit/prompts/<slug>.md`
7. **Verify substitution** by mentally (or actually) replacing `$1`/`$@` with a sample value and confirming every reference resolves — and that the prompt's *own* example snippets don't accidentally bump the required-arg count (wrap illustrative `$N` examples in triple-backtick fences, not 4-space indentation, so `RequiredArgs()` ignores them)
8. **Confirm** by showing the final file content and the slash command that activates it (e.g. `/code-review 42`)
## Guidelines
@@ -47,3 +81,4 @@ $1 $2 etc. for positional arguments.
- Prefer concrete steps over vague instructions
- A prompt that does one thing well beats one that tries to cover every edge case
- If the workflow already exists as a prompt, suggest extending it instead of duplicating
- When in doubt about substitution behaviour, write the file and run `/<slug> testvalue` once to confirm — wrong placement of backticks is the #1 failure mode
+52
View File
@@ -0,0 +1,52 @@
---
description: Audit and update project documentation (README and docs site) for a recent change
---
Review recent code changes, identify all documentation surfaces that should
mention them, and update each one — grounded in the actual diff, not guesses.
## Steps
1. **Identify the change**:
- If the user input ($@) names a commit / PR / branch / topic, use that as the focus
- Otherwise inspect `git log origin/main..HEAD --oneline` and `git diff origin/main...HEAD --stat` to discover what shipped on the current branch
- Read the actual diff (`git diff origin/main...HEAD`) — never document features that aren't in the code
2. **Inventory the doc surfaces**:
- `README.md` at the repo root
- Any docs site (commonly `www/`, `docs/`, `site/`) — list its pages and identify the one(s) most thematically related to the change
- Inline godoc / API reference comments on the new exported symbols
- `CHANGELOG.md` if the project keeps one
- Any `examples/` directory entries that demonstrate the affected area
3. **Audit each surface** with `grep`:
- Search for the names of related existing APIs (e.g. if you added `IterTools`, grep for `ListTools`) to find every page that already discusses the area
- Decide for each hit: does it need a cross-reference, a side-by-side comparison, or to stay untouched?
4. **Decide where new content lives**:
- Prefer extending an existing page over creating a new one
- For a docs site, place new sections near related content (check the page's `## Heading` outline first)
- Skip surfaces that genuinely don't apply (e.g. a server-focused README for a client-only change) and say so explicitly
5. **Draft the updates**:
- Lead with a one-sentence statement of what's new and why
- Show concrete code examples copied from real signatures — verify against the source files
- Include a comparison / "when to use which" table when adding an alternative to an existing API
- Note backwards-compatibility behaviour if relevant
6. **Verify the docs build** before committing:
- For vocs / docusaurus / mkdocs sites, run the local build command (e.g. `npx vocs build`, `mkdocs build`) and fix any MDX/markdown errors
- For godoc, run `go vet ./...` and `go doc <pkg> <Symbol>` to sanity-check rendering
7. **Report**:
- List every file changed and every file deliberately left alone (with a one-line reason)
- Suggest the next step (typically `/commit-push`) — do not auto-commit unless asked
## Guidelines
- Read the diff before writing anything — invented API names erode trust faster than missing docs
- One change per doc commit; keep doc updates separate from code changes when possible
- Match the existing voice and formatting of each surface (headings, code-fence languages, table styles)
- Prefer linking between pages over duplicating content
$@
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"external_directory": {
"~/go/**": "deny"
}
}
}
-80
View File
@@ -1,80 +0,0 @@
# Autoscroll Fix - Final Summary
## Root Cause
The autoscroll was failing for streaming assistant messages due to a bug in how `GotoBottom()` calculated item heights.
### The Problem
1. **Reasoning blocks** (`StreamingMessageItem` with `role="reasoning"`) are **never cached** because they have live duration counters that update every render
2. The `Height()` method returns `0` when `cachedRender == ""`
3. `GotoBottom()` was calling:
```go
itemHeight := item.Height() // Returns 0 for reasoning
if itemHeight == 0 {
item.Render(s.width) // Renders but doesn't cache (reasoning)
itemHeight = item.Height() // Still returns 0!
}
```
4. This caused incorrect scroll position calculations, especially during reasoning → assistant transitions
## The Solution
Changed `GotoBottom()` and `AtBottom()` to calculate height **directly from the rendered string** instead of relying on the cached height:
```go
// OLD: item.Height() which checks cached render
itemHeight := item.Height()
if itemHeight == 0 {
item.Render(s.width)
itemHeight = item.Height() // Still might be 0!
}
// NEW: Calculate from rendered string directly
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
```
This works for **all** items regardless of whether they cache their render or not.
## Files Changed
### `internal/ui/scrolllist.go`
- **`GotoBottom()`**: Calculate height from rendered string (2 loops)
- **`AtBottom()`**: Calculate height from rendered string (1 loop)
### `internal/ui/model.go`
- **`appendStreamingChunk()`**: For existing messages, call `GotoBottom()` directly (iteratr pattern)
- **`refreshContent()`**: Simplified to only call `SetItems()` (removed redundant `GotoBottom()`)
- **Bash streaming handler**: Removed redundant `GotoBottom()` after `refreshContent()`
## Testing Results
✅ **Test prompt**: "explore this repo"
**Before fix**:
- Autoscroll stopped after reasoning block completed
- Viewport stuck showing end of reasoning ("Thought for 203ms")
- Assistant response streamed off-screen below
**After fix**:
- Autoscroll works throughout reasoning block
- Autoscroll continues during reasoning → assistant transition
- Viewport stays at bottom showing latest assistant content
- Final position shows end of response (build commands section)
## Behavior Verified
1. ✅ Streaming text auto-scrolls to bottom
2. ✅ Works across reasoning → assistant transition
3. ✅ Manual scroll up (PgUp) disables autoscroll
4. ✅ Scroll to bottom (Alt+End) re-enables autoscroll
5. ✅ Accurate positioning with no offset errors
## Performance Note
The fix calls `Render()` on all items during `GotoBottom()` calculations. This is acceptable because:
- `Render()` is already optimized with caching for non-reasoning items
- `GotoBottom()` is only called during content updates (not every frame)
- Reasoning blocks need to render anyway for live duration updates
- This matches iteratr's approach of ensuring items are rendered before height calculations
+256 -13
View File
@@ -18,7 +18,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
## Features
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, subagent - no MCP overhead
- **Built-in Core Tools**: bash (with interactive sudo password prompt), read, write, edit, grep, find, ls, subagent - no MCP overhead
- **Smart @ Attachments**: Binary files auto-detected via MIME type, MCP resources via `@mcp:server:uri`
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
@@ -29,7 +29,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK**: Embed Kit in your own applications
- **Go SDK**: Embed Kit in your own applications with full agent lifecycle events (30+ event types) and behavior-modifying hooks
## Installation
@@ -126,8 +126,20 @@ model: anthropic/claude-sonnet-latest
max-tokens: 4096
temperature: 0.7
stream: true
thinking-level: off # off, none, minimal, low, medium, high
no-core-tools: false # set to true to disable all built-in core tools
# Skills — all three keys are optional
no-skills: false # set to true to disable all skill loading
skill: # explicit skill files/dirs (disables auto-discovery)
- /path/to/skill.md
skills-dir: "" # override project-local directory for auto-discovery
```
All of the above keys can also be set programmatically via the SDK
(`kit.Options.MaxTokens`, `Options.Temperature`, `Options.ThinkingLevel`, etc.)
without touching config files — see [SDK options](#with-options).
### Environment Variables
```bash
@@ -152,6 +164,16 @@ mcpServers:
search:
type: remote
url: "https://mcp.example.com/search"
pubmed:
type: remote
url: "https://pubmed.mcp.example.com"
noOAuth: true # skip OAuth for public servers that don't require auth
builds:
type: remote
url: "https://builds.mcp.example.com"
tasksMode: always # async task execution — see MCP Tasks below
```
## CLI Reference
@@ -180,21 +202,27 @@ mcpServers:
--compact Enable compact output mode
--auto-compact Auto-compact conversation near context limit
# Extensions
# Extensions and tools
--extension, -e Load additional extension file(s) (repeatable)
--no-extensions Disable all extensions
--no-core-tools Disable all built-in core tools (bash, read, write, edit, grep, find, ls, subagent)
--prompt-template Load a specific prompt template by name
--no-prompt-templates Disable prompt template loading
# Skills
--skill Load skill file or directory (repeatable)
--skills-dir Override the project-local skills directory for auto-discovery
--no-skills Disable skill loading (auto-discovery and explicit)
# Generation parameters
--max-tokens Maximum tokens in response (default: 4096)
--max-tokens Maximum tokens in response (default: 8192, auto-raised up to 32768 for models with larger known output limits)
--temperature Randomness 0.0-1.0 (default: 0.7)
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
--top-k Limit top K tokens (default: 40)
--stop-sequences Custom stop sequences (comma-separated)
--frequency-penalty Penalize frequent tokens 0.0-2.0 (default: 0.0)
--presence-penalty Penalize present tokens 0.0-2.0 (default: 0.0)
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
--thinking-level Extended thinking level: off, none, minimal, low, medium, high (default: off)
# System
--config Config file path (default: ~/.kit.yml)
@@ -206,9 +234,14 @@ mcpServers:
```bash
# Authentication (for OAuth-enabled providers)
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth login [provider] --set-default # Set provider's default model as system default
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
# GitHub Copilot login (experimental; requires active Copilot subscription)
kit auth login copilot
kit --model copilot/gpt-5.5 "Hello"
# Model database
kit models [provider] # List available models (optionally filter by provider)
@@ -290,12 +323,15 @@ kit -e examples/extensions/minimal.go
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnLLMUsage, OnToolCall, OnToolCallInputStart, OnToolCallInputDelta, OnToolCallInputEnd, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
`OnAgentEnd` carries per-turn aggregates (`ToolCallCount`, `ToolNames`, `LLMCallCount`, `InputTokensDelta`, `OutputTokensDelta`, `CostDelta`, `DurationMs`) so observers don't need to maintain parallel bookkeeping. `OnLLMUsage` fires after each LLM provider call with token + cost deltas attributed to that specific call/model — use it for accurate budget enforcement *between* calls instead of waiting for the turn to finish.
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
- **Commands**: Register slash commands (e.g., `/mycommand`)
- **Options**: Register configurable extension options
- **Session State**: Last-write-wins key-value store via `ctx.SetState` / `GetState` / `DeleteState` / `ListState`, persisted to a per-session sidecar file outside the conversation tree
- **Widgets**: Persistent status displays above/below input
- **Headers/Footers**: Persistent content above/below the conversation
- **Status Bar**: Custom status bar entries
@@ -351,6 +387,7 @@ See the `examples/extensions/` directory:
- [`tool-logger.go`](examples/extensions/tool-logger.go) - Log all tool calls
- [`neon-theme.go`](examples/extensions/neon-theme.go) - Custom theme registration and switching
- [`tool-renderer-demo.go`](examples/extensions/tool-renderer-demo.go) - Custom tool call rendering
- [`usage-budget.go`](examples/extensions/usage-budget.go) - Per-call usage callback (`OnLLMUsage`), session state, and enriched `OnAgentEnd` per-turn report
- [`widget-status.go`](examples/extensions/widget-status.go) - Persistent status widgets
Also see [`.kit/extensions/go-edit-lint.go`](.kit/extensions/go-edit-lint.go) (in this repo) for a project-local extension example that runs gopls and golangci-lint on Go file edits.
@@ -491,6 +528,8 @@ During an interactive session, use these slash commands:
| Shortcut | Description |
|----------|-------------|
| `Ctrl+V` | Paste an image from the clipboard — shows an inline low-res thumbnail preview (tmux/zellij-safe) |
| `Ctrl+U` | Clear all pending image attachments |
| `Ctrl+X e` | Open `$VISUAL`/`$EDITOR` to compose or edit your prompt |
| `Ctrl+X s` | Steer — inject a system-level instruction mid-turn |
| `ESC ESC` | Cancel the current operation (tool call or streaming) |
@@ -538,9 +577,23 @@ host, err := kit.New(ctx, &kit.Options{
SystemPrompt: "You are a helpful bot",
ConfigFile: "/path/to/config.yml",
MaxSteps: 10,
Streaming: true,
Streaming: ptr(true), // *bool: nil = unset (default true), &false = off
Quiet: true,
// Generation parameters (override env/config/per-model defaults)
MaxTokens: 16384, // 0 = auto-resolve (env → config → per-model → 8192 floor)
ThinkingLevel: "medium", // "off", "none", "minimal", "low", "medium", "high"
Temperature: ptr(float32(0.2)), // pointer so 0.0 != unset; nil = provider default
TopP: nil, // nil = leave provider/per-model default
TopK: nil,
FrequencyPenalty: nil,
PresencePenalty: nil,
// Provider configuration (override env/config without reaching into viper)
ProviderAPIKey: "sk-...", // "" = use config / provider env var
ProviderURL: "https://proxy.internal/v1", // "" = provider default
TLSSkipVerify: false, // only takes effect when true
// Session options
SessionPath: "./session.jsonl", // Open specific session
Continue: true, // Resume most recent session
@@ -549,7 +602,9 @@ host, err := kit.New(ctx, &kit.Options{
// Tool options
Tools: []kit.Tool{...}, // Replace default tool set entirely
ExtraTools: []kit.Tool{...}, // Add tools alongside defaults
DisableCoreTools: true, // Use no core tools (0 tools, for chat-only)
DisableCoreTools: true, // Disable all built-in core tools; also controllable via
// --no-core-tools flag, KIT_NO_CORE_TOOLS env var,
// or no-core-tools: true in .kit.yml
// Configuration
SkipConfig: true, // Skip .kit.yml files (viper defaults + env vars still apply)
@@ -561,6 +616,108 @@ host, err := kit.New(ctx, &kit.Options{
})
```
**Generation & provider fields** (added in v0.55+) let SDK consumers configure
Kit entirely in-code without `viper.Set()` workarounds or shipping a `.kit.yml`.
Precedence is `Options` > `KIT_*` env vars > `.kit.yml` > per-model defaults
(`modelSettings` / `customModels`) > provider-level defaults. Sampling params
are pointer types so explicit `0.0` is distinguishable from "leave alone"; a
non-zero `MaxTokens` suppresses automatic right-sizing the same way `--max-tokens`
does on the CLI.
### Functional options (`NewAgent`)
For simple programmatic setups, `kit.NewAgent` offers an ergonomic
functional-options front door over `kit.New`. Streaming is **enabled by
default**; pass `kit.WithStreaming(false)` to opt out.
```go
host, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithSystemPrompt("You are a helpful assistant."),
kit.WithMaxTokens(8192),
kit.WithThinkingLevel("medium"),
kit.Ephemeral(), // in-memory session, no persistence
)
```
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`.
### Per-instance config isolation
Each `kit.New` / `kit.NewAgent` call owns an **isolated configuration store**,
so constructing multiple Kit instances in the same process is safe: setting the
model, thinking level, or generation parameters on one never affects another,
and runtime mutators (`SetModel`, `SetThinkingLevel`) only touch the owning
instance. This makes subagent spawning and multi-Kit embedding race-free with
no external synchronization required.
### MCP OAuth (remote MCP servers)
When a remote MCP server returns 401, Kit runs the full OAuth flow (dynamic
client registration → PKCE → token exchange → persistence) but delegates the
user-facing step — showing the authorization URL and receiving the callback —
to an `MCPAuthHandler` that you pass explicitly via `Options.MCPAuthHandler`.
If nil, OAuth is disabled and the authorization-required error surfaces to the
caller; the SDK never auto-opens a browser or binds a localhost port.
```go
// CLI/TUI apps: opens the system browser + prints status to stderr.
authHandler, _ := kit.NewCLIMCPAuthHandler()
defer authHandler.Close()
host, _ := kit.New(ctx, &kit.Options{
MCPAuthHandler: authHandler,
})
// Custom UX: reuse the SDK's port + callback server, supply your own
// presentation via OnAuthURL (TUI modal, QR code, web redirect, etc.).
// h, _ := kit.NewDefaultMCPAuthHandler()
// h.OnAuthURL = func(server, authURL string) { myUI.Show(server, authURL) }
//
// Full control (web apps, daemons): implement kit.MCPAuthHandler yourself —
// no localhost binding, no side effects.
```
Tokens are persisted to `$XDG_CONFIG_HOME/.kit/mcp_tokens.json` by default; swap
in a custom `MCPTokenStoreFactory` for encrypted, DB-backed, or in-memory
storage. See the [SDK options docs](/sdk/options#mcp-oauth-authorization) for
the full matrix.
### MCP Tasks (long-running tools)
Kit advertises [MCP task support](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)
during `initialize`, so cooperating MCP servers can respond to `tools/call`
with a `taskId` instead of blocking the connection. Kit then polls
`tasks/get` / `tasks/result` until the task reaches a terminal state, and
best-effort `tasks/cancel`s on context cancellation.
Defaults are safe — a server that doesn't advertise task capability runs
synchronously, exactly as before. Opt in per server via `tasksMode` in
`.kit.yml` (`auto` | `never` | `always`) or programmatically through the SDK:
```go
host, _ := kit.New(ctx, &kit.Options{
MCPTaskMode: map[string]kit.MCPTaskMode{
"build-server": kit.MCPTaskModeAlways,
},
MCPTaskTimeout: 15 * time.Minute,
MCPTaskProgress: func(p kit.MCPTaskProgress) {
log.Printf("%s: %s", p.TaskID, p.Status)
},
})
tasks, _ := host.ListMCPTasks(ctx, "build-server")
_, _ = host.CancelMCPTask(ctx, "build-server", tasks[0].TaskID)
```
See the [configuration docs](/configuration#mcp-tasks-long-running-tools) and
[SDK options → MCP Tasks](/sdk/options#mcp-tasks) for the full surface.
### Custom Tools
Create custom tools with automatic schema generation — no external dependencies needed:
@@ -581,7 +738,28 @@ host, _ := kit.New(ctx, &kit.Options{
})
```
Use `kit.NewParallelTool` for tools safe to run concurrently. See the [SDK docs](/sdk/overview) for full details on struct tags, `ToolOutput` fields, and `ToolCallIDFromContext`.
Use `kit.NewParallelTool` for tools safe to run concurrently. Binary data (images, audio, etc.) in `ToolOutput.Data` is automatically forwarded to the LLM when `MediaType` is set. See the [SDK docs](/sdk/overview) for full details on struct tags, `ToolOutput` fields, and `ToolCallIDFromContext`.
#### Return Helpers
| Helper | Description |
| --- | --- |
| `kit.TextResult(content)` | Successful text result |
| `kit.ErrorResult(content)` | Error result (LLM sees it as a tool error) |
| `kit.ImageResult(content, data, mediaType)` | Image result with binary data (e.g. `"image/png"`) |
| `kit.MediaResult(content, data, mediaType)` | Non-image media result (e.g. `"audio/mpeg"`) |
#### ToolOutput Fields
```go
kit.ToolOutput{
Content: "result text", // text returned to the LLM
IsError: false, // true = LLM sees this as an error
Data: pngBytes, // optional binary data (images, audio)
MediaType: "image/png", // MIME type for binary Data
Metadata: map[string]any{}, // opaque metadata for hooks/UI (not sent to LLM)
}
```
### With Callbacks
@@ -598,7 +776,7 @@ unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
})
defer unsub2()
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
unsub3 := host.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
print(e.Chunk)
})
defer unsub3()
@@ -635,6 +813,45 @@ host, _ := kit.New(ctx, &kit.Options{
})
```
### Runtime Skills & Context Files
For multi-tenant hosts (chatbots, per-user agents, web services), the SDK
lets you swap skills and `AGENTS.md`-style context files **after** Kit
construction. Every mutation recomposes the system prompt and applies it to
the agent so the next turn picks up the new instructions — no restart needed.
```go
// Programmatic skill (no file on disk required).
host.AddSkill(&kit.Skill{
Name: "polite-french",
Description: "Respond in French and always greet the user.",
Content: "Always reply in French. Open every response with 'Bonjour'.",
})
// Or load one from disk.
host.LoadAndAddSkill("/var/skills/refund-policy.md")
// Per-user AGENTS.md content pulled from a database.
host.AddContextFileContent(
fmt.Sprintf("session://%s/AGENTS.md", userID),
rulesFromDB,
)
// Tear down session-specific state on logout.
host.RemoveSkill("polite-french")
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
// Or replace the whole set atomically.
host.SetSkills(activeSkillsForUser)
host.SetContextFiles(activeContextForUser)
```
Skills dedupe by `Name`, context files dedupe by `Path` (which can be any
opaque identifier — it doesn't have to be a real filesystem path). All
mutators and readers (`GetSkills`, `GetContextFiles`) are safe to call
concurrently from multiple goroutines. See the [SDK overview docs](/sdk/overview#runtime-skills-and-context-files)
for the full reference.
## Advanced Usage
### Subagent Pattern
@@ -751,6 +968,7 @@ npm/ - NPM package wrapper for distribution
- **Anthropic** - Claude models (native, prompt caching, OAuth)
- **OpenAI** - GPT models
- **Copilot** - GitHub Copilot models (`copilot`, requires active Copilot subscription)
- **Google** - Gemini models
- **Ollama** - Local models
- **Azure OpenAI** - Azure-hosted OpenAI
@@ -776,6 +994,31 @@ This automatically defaults to `custom/custom` without needing to specify a mode
- Reasoning and temperature support
- Optional `CUSTOM_API_KEY` environment variable or `--provider-api-key` flag
### Auto-routed Providers
Any provider in the [models.dev](https://models.dev) database can be used as
`provider/model` without a dedicated native integration. Kit auto-routes the
request through the matching **wire protocol** based on the provider's npm package
(or per-model override), using its `api` URL as the base:
| npm package | Wire protocol |
|-------------|---------------|
| `@ai-sdk/openai` | OpenAI (Responses API) |
| `@ai-sdk/openai-compatible` | OpenAI (chat completions) |
| `@ai-sdk/anthropic` | Anthropic |
| `@ai-sdk/google` | Google Gemini |
Providers with an `api` URL but an unrecognized npm package fall back to the
OpenAI-compatible wire. Because routing follows the wire protocol, aggregator/proxy
providers work across all of their models — including Claude, GPT, *and* Gemini
routes:
```bash
kit --model opencode/claude-haiku-4-5 "Hello" # → Anthropic wire
kit --model opencode/gpt-5 "Hello" # → OpenAI wire
kit --model opencode/gemini-3.5-flash "Hello" # → Google wire
```
### Model String Format
```bash
+220 -7
View File
@@ -11,6 +11,7 @@ import (
"charm.land/huh/v2"
"github.com/mark3labs/kit/internal/auth"
"github.com/mark3labs/kit/internal/ui"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
)
@@ -30,10 +31,12 @@ using OAuth flows. Stored credentials take precedence over environment variables
Available providers:
- anthropic: Anthropic Claude API (OAuth)
- openai: OpenAI API (OAuth and API key)
- copilot: GitHub Copilot (GitHub device login)
Examples:
kit auth login anthropic
kit auth login openai
kit auth login copilot
kit auth logout anthropic
kit auth status`,
}
@@ -53,10 +56,16 @@ environment variables when making API calls.
Available providers:
- anthropic: Anthropic Claude API (OAuth)
- openai: OpenAI ChatGPT Plus/Pro (Codex OAuth)
- copilot: GitHub Copilot (GitHub device login, experimental)
Example:
Flags:
--set-default Set this provider's default model as the system default
Examples:
kit auth login anthropic
kit auth login openai`,
kit auth login openai
kit auth login copilot
kit auth login copilot --set-default`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogin,
}
@@ -75,10 +84,12 @@ You will need to use environment variables or command-line flags for authenticat
Available providers:
- anthropic: Anthropic Claude API
- openai: OpenAI API
- copilot: GitHub Copilot
Example:
kit auth logout anthropic
kit auth logout openai`,
kit auth logout openai
kit auth logout copilot`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogout,
}
@@ -99,12 +110,47 @@ Example:
RunE: runAuthStatus,
}
var (
loginSetDefault bool
)
// defaultModels maps providers to their recommended default models.
// These are used when --set-default flag is passed to auth login.
var defaultModels = map[string]string{
"anthropic": "anthropic/claude-sonnet-4-5-20250929",
"openai": "openai/gpt-5.4",
"copilot": "copilot/gpt-5.5",
}
// setDefaultModelIfRequested sets the default model for the given provider
// if the --set-default flag was provided.
func setDefaultModelIfRequested(provider string) error {
if !loginSetDefault {
return nil
}
model, ok := defaultModels[provider]
if !ok {
return fmt.Errorf("no default model configured for provider: %s", provider)
}
if err := ui.SaveModelPreference(model); err != nil {
return fmt.Errorf("failed to save model preference: %w", err)
}
fmt.Printf("\n✓ Set default model to: %s\n", model)
return nil
}
func init() {
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authStatusCmd)
authLoginCmd.Flags().BoolVar(&loginSetDefault, "set-default", false, "Set this provider's default model as the system default after login")
}
// runAuthLogin dispatches OAuth login to the selected provider.
func runAuthLogin(cmd *cobra.Command, args []string) error {
provider := strings.ToLower(args[0])
@@ -113,8 +159,10 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
return loginAnthropic()
case "openai":
return loginOpenAI()
case "copilot":
return loginCopilot(cmd.Context())
default:
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai, copilot", provider)
}
}
@@ -126,8 +174,10 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
return logoutAnthropic()
case "openai":
return logoutOpenAI()
case "copilot":
return logoutCopilot()
default:
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai, copilot", provider)
}
}
@@ -206,9 +256,31 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
}
}
// Check GitHub Copilot credentials
fmt.Print("\nGitHub Copilot: ")
if hasCopilotCreds, err := cm.HasCopilotCredentials(); err != nil {
fmt.Printf("Error checking credentials: %v\n", err)
} else if hasCopilotCreds {
if creds, err := cm.GetCopilotCredentials(); err != nil {
fmt.Printf("Error reading credentials: %v\n", err)
} else {
status := "✓ Authenticated"
if creds.IsExpired() {
status = "⚠️ Token expired (will refresh automatically)"
} else if creds.NeedsRefresh() {
status = "⚠️ Token expires soon (will refresh automatically)"
}
fmt.Printf("%s (GitHub OAuth, stored %s)\n", status, creds.CreatedAt.Format("2006-01-02 15:04:05"))
}
} else {
fmt.Println("✗ Not authenticated")
}
fmt.Println("\nTo authenticate with a provider:")
fmt.Println(" kit auth login anthropic")
fmt.Println(" kit auth login openai")
fmt.Println(" kit auth login copilot")
return nil
}
@@ -288,6 +360,17 @@ func loginAnthropic() error {
fmt.Println("\n🎉 Your OAuth credentials will now be used for Anthropic API calls.")
fmt.Println("💡 You can check your authentication status with: kit auth status")
// Set default model if requested
if err := setDefaultModelIfRequested("anthropic"); err != nil {
return err
}
// Remind users how to set this as default if they didn't use --set-default
if !loginSetDefault {
fmt.Println("\n💡 To set Anthropic as your default model, run:")
fmt.Println(" kit auth login anthropic --set-default")
}
return nil
}
@@ -454,6 +537,96 @@ func loginOpenAI() error {
fmt.Println("\n🎉 Your OAuth credentials will now be used for OpenAI API calls.")
fmt.Println("💡 You can check your authentication status with: kit auth status")
// Set default model if requested
if err := setDefaultModelIfRequested("openai"); err != nil {
return err
}
// Remind users how to set this as default if they didn't use --set-default
if !loginSetDefault {
fmt.Println("\n💡 To set OpenAI as your default model, run:")
fmt.Println(" kit auth login openai --set-default")
}
return nil
}
// loginCopilot authenticates GitHub Copilot using GitHub device flow.
func loginCopilot(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
cm, err := kit.NewCredentialManager()
if err != nil {
return fmt.Errorf("failed to initialize credential manager: %w", err)
}
if hasAuth, err := cm.HasCopilotCredentials(); err == nil && hasAuth {
var reauth bool
err := huh.NewConfirm().
Title("You are already authenticated with GitHub Copilot").
Description("Do you want to re-authenticate?").
Affirmative("Yes").
Negative("No").
Value(&reauth).
Run()
if err != nil {
return fmt.Errorf("failed to prompt for re-authentication: %w", err)
}
if !reauth {
fmt.Println("Authentication cancelled.")
return nil
}
}
client := auth.NewCopilotOAuthClient()
fmt.Println("🔐 Starting GitHub Copilot authentication...")
fmt.Println("This uses GitHub device login and requires an active GitHub Copilot subscription.")
fmt.Println("Experimental: this uses VS Code Copilot Chat client identifiers.")
fmt.Println()
deviceCode, err := client.StartDeviceFlow(ctx)
if err != nil {
return fmt.Errorf("failed to start GitHub device login: %w", err)
}
fmt.Println("📱 Open this page and enter the code:")
fmt.Printf("\n%s\n\n", deviceCode.VerificationURI)
fmt.Printf("Code: %s\n\n", deviceCode.UserCode)
auth.TryOpenBrowser(deviceCode.VerificationURI)
fmt.Println("Waiting for GitHub authorization...")
githubToken, err := client.PollDeviceToken(ctx, deviceCode)
if err != nil {
return fmt.Errorf("failed to complete GitHub device login: %w", err)
}
fmt.Println("\n🔄 Exchanging GitHub token for Copilot access token...")
creds, err := client.ExchangeGitHubToken(ctx, githubToken)
if err != nil {
return fmt.Errorf("failed to get GitHub Copilot token: %w", err)
}
if err := cm.SetCopilotOAuthCredentials(creds); err != nil {
return fmt.Errorf("failed to store credentials: %w", err)
}
fmt.Println("✅ Successfully authenticated with GitHub Copilot!")
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
fmt.Println("\n🎉 Your GitHub Copilot credentials will now be used for copilot/* models.")
fmt.Println("💡 You can check your authentication status with: kit auth status")
if err := setDefaultModelIfRequested("copilot"); err != nil {
return err
}
if !loginSetDefault {
fmt.Println("\n💡 To set Copilot as your default model, run:")
fmt.Println(" kit auth login copilot --set-default")
}
return nil
}
@@ -504,13 +677,13 @@ func startOpenAICallbackServer(expectedState string) (*callbackServer, error) {
}
// Return success page
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head><title>Authentication Successful</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1> Authentication Successful</h1>
<h1>&#10003; Authentication Successful</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>`)
@@ -575,3 +748,43 @@ func logoutOpenAI() error {
return nil
}
func logoutCopilot() error {
cm, err := kit.NewCredentialManager()
if err != nil {
return fmt.Errorf("failed to initialize credential manager: %w", err)
}
hasAuth, err := cm.HasCopilotCredentials()
if err != nil {
return fmt.Errorf("failed to check authentication status: %w", err)
}
if !hasAuth {
fmt.Println("You are not currently authenticated with GitHub Copilot.")
return nil
}
var confirm bool
err = huh.NewConfirm().
Title("Remove GitHub Copilot credentials").
Description("Are you sure you want to remove your stored credentials?").
Affirmative("Yes").
Negative("No").
Value(&confirm).
Run()
if err != nil || !confirm {
fmt.Println("Logout cancelled.")
return nil
}
if err := cm.RemoveCopilotCredentials(); err != nil {
return fmt.Errorf("failed to remove credentials: %w", err)
}
fmt.Println("✓ Successfully logged out from GitHub Copilot!")
fmt.Println("You will need to authenticate again with 'kit auth login copilot'.")
fmt.Println("Tip: this removes local credentials only. Revoke the GitHub OAuth grant at https://github.com/settings/applications")
return nil
}
+308
View File
@@ -0,0 +1,308 @@
package cmd
import (
"context"
"fmt"
"os"
"github.com/spf13/viper"
"golang.org/x/term"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/extbridge"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/ui"
kit "github.com/mark3labs/kit/pkg/kit"
)
// extensionContextDeps groups the runtime dependencies needed to wire up
// an extensions.Context for the interactive TUI mode.
type extensionContextDeps struct {
ctx context.Context
cwd string
modelName string
interactive bool
kitInstance *kit.Kit
appInstance *app.App
usageTracker *ui.UsageTracker
}
// buildInteractiveExtensionContext returns an extensions.Context with every
// field except Print / PrintInfo / PrintError populated. Callers must set
// the three print routes appropriately for their phase (startup buffering
// vs. live runtime routing).
//
// The headless half (data access, state, options, tree navigation, skills,
// templates, model resolution, subagents) comes from extbridge.BaseContext;
// this function overlays the TUI-specific fields and overrides SetModel /
// ReloadExtensions with TUI-aware versions.
func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Context {
kitInstance := deps.kitInstance
appInstance := deps.appInstance
usageTracker := deps.usageTracker
ec := extbridge.BaseContext(deps.ctx, kitInstance)
ec.CWD = deps.cwd
ec.Model = deps.modelName
ec.Interactive = deps.interactive
ec.PrintBlock = func(opts extensions.PrintBlockOpts) {
appInstance.PrintBlockFromExtension(opts)
}
ec.SendMessage = func(text string) { appInstance.Run(text) }
ec.CancelAndSend = func(text string) { appInstance.InterruptAndSend(text) }
ec.Abort = func() { appInstance.Abort() }
ec.IsIdle = func() bool { return !appInstance.IsBusy() }
ec.Compact = func(cfg extensions.CompactConfig) error {
return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError)
}
ec.SendMultimodalMessage = func(text string, files []extensions.FilePart) {
parts := make([]kit.LLMFilePart, len(files))
for i, f := range files {
parts[i] = kit.LLMFilePart{
Filename: f.Filename,
Data: f.Data,
MediaType: f.MediaType,
}
}
appInstance.RunWithFiles(text, parts)
}
ec.GetSessionUsage = func() extensions.SessionUsage {
if usageTracker == nil {
return extensions.SessionUsage{}
}
stats := usageTracker.GetSessionStats()
return extensions.SessionUsage{
TotalInputTokens: stats.TotalInputTokens,
TotalOutputTokens: stats.TotalOutputTokens,
TotalCacheReadTokens: stats.TotalCacheReadTokens,
TotalCacheWriteTokens: stats.TotalCacheWriteTokens,
TotalCost: stats.TotalCost,
RequestCount: stats.RequestCount,
}
}
ec.Exit = func() { appInstance.QuitFromExtension() }
// TUI widgets/chrome — mutate runner state, then notify the TUI.
// Always use a goroutine for NotifyWidgetUpdate: prog.Send() deadlocks
// if called synchronously from inside BubbleTea's Update() handler.
// All call sites use go-routines uniformly.
ec.SetWidget = func(config extensions.WidgetConfig) {
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
}
ec.RemoveWidget = func(id string) {
kitInstance.Extensions().RemoveWidget(id)
go appInstance.NotifyWidgetUpdate()
}
ec.SetHeader = func(config extensions.HeaderFooterConfig) {
kitInstance.Extensions().SetHeader(config)
go appInstance.NotifyWidgetUpdate()
}
ec.RemoveHeader = func() {
kitInstance.Extensions().RemoveHeader()
go appInstance.NotifyWidgetUpdate()
}
ec.SetFooter = func(config extensions.HeaderFooterConfig) {
kitInstance.Extensions().SetFooter(config)
go appInstance.NotifyWidgetUpdate()
}
ec.RemoveFooter = func() {
kitInstance.Extensions().RemoveFooter()
go appInstance.NotifyWidgetUpdate()
}
ec.SetUIVisibility = func(v extensions.UIVisibility) {
kitInstance.Extensions().SetUIVisibility(v)
go appInstance.NotifyWidgetUpdate()
}
ec.SetEditor = func(config extensions.EditorConfig) {
kitInstance.Extensions().SetEditor(config)
go appInstance.NotifyWidgetUpdate()
}
ec.ResetEditor = func() {
kitInstance.Extensions().ResetEditor()
go appInstance.NotifyWidgetUpdate()
}
ec.SetEditorText = func(text string) {
appInstance.SetEditorTextFromExtension(text)
}
ec.SetStatus = func(key string, text string, priority int) {
kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
})
go appInstance.NotifyWidgetUpdate()
}
ec.RemoveStatus = func(key string) {
kitInstance.Extensions().RemoveStatus(key)
go appInstance.NotifyWidgetUpdate()
}
// Interactive prompts — channel-based round trips through the TUI.
ec.PromptSelect = func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "select",
Message: config.Message,
Options: config.Options,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptSelectResult{Cancelled: true}
}
return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index}
}
ec.PromptConfirm = func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
ch := make(chan app.PromptResponse, 1)
def := "false"
if config.DefaultValue {
def = "true"
}
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "confirm",
Message: config.Message,
Default: def,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptConfirmResult{Cancelled: true}
}
return extensions.PromptConfirmResult{Value: resp.Confirmed}
}
ec.PromptInput = func(config extensions.PromptInputConfig) extensions.PromptInputResult {
ch := make(chan app.PromptResponse, 1)
appInstance.SendPromptRequest(app.PromptRequestEvent{
PromptType: "input",
Message: config.Message,
Placeholder: config.Placeholder,
Default: config.Default,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.PromptInputResult{Cancelled: true}
}
return extensions.PromptInputResult{Value: resp.Value}
}
ec.ShowOverlay = func(config extensions.OverlayConfig) extensions.OverlayResult {
ch := make(chan app.OverlayResponse, 1)
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
Title: config.Title,
Content: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
Background: config.Style.Background,
Width: config.Width,
MaxHeight: config.MaxHeight,
Anchor: string(config.Anchor),
Actions: config.Actions,
ResponseCh: ch,
})
resp := <-ch
if resp.Cancelled {
return extensions.OverlayResult{Cancelled: true, Index: -1}
}
return extensions.OverlayResult{
Action: resp.Action,
Index: resp.Index,
}
}
ec.SuspendTUI = func(callback func()) error {
return appInstance.SuspendTUI(callback)
}
// TUI-aware model switch: also notifies the TUI status bar and
// refreshes the usage tracker for correct token counting.
ec.SetModel = func(modelString string) error {
// Capture previous model for the ModelChange event.
previousModel := kitInstance.Extensions().GetContext().Model
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
}
// Notify TUI so it updates model in status bar.
p, m, _ := models.ParseModelString(modelString)
appInstance.NotifyModelChanged(p, m)
// Update the context's Model field so handlers see it.
kitInstance.Extensions().UpdateContextModel(modelString)
// Fire OnModelChange event to extensions.
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
// Update usage tracker with new model info for correct token counting.
ui.UpdateUsageTrackerForModel(usageTracker, modelString, viper.GetString("provider-api-key"))
return nil
}
ec.RenderMessage = func(rendererName, content string) {
renderer := kitInstance.Extensions().GetMessageRenderer(rendererName)
if renderer == nil || renderer.Render == nil {
appInstance.PrintFromExtension("", content)
return
}
w, _, _ := term.GetSize(int(os.Stdout.Fd()))
if w == 0 {
w = 80
}
rendered := renderer.Render(content, w)
appInstance.PrintFromExtension("", rendered)
}
ec.ReloadExtensions = func() error {
err := kitInstance.Extensions().Reload()
if err != nil {
return err
}
// Notify TUI that widgets/status/commands may have changed.
go appInstance.NotifyWidgetUpdate()
return nil
}
// Theme management (TUI only).
ec.RegisterTheme = func(name string, config extensions.ThemeColorConfig) {
tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} }
ui.RegisterThemeFromConfig(name,
tc(config.Primary), tc(config.Secondary),
tc(config.Success), tc(config.Warning),
tc(config.Error), tc(config.Info),
tc(config.Text), tc(config.Muted),
tc(config.VeryMuted), tc(config.Background),
tc(config.Border), tc(config.MutedBorder),
tc(config.System), tc(config.Tool),
tc(config.Accent), tc(config.Highlight),
tc(config.MdHeading), tc(config.MdLink),
tc(config.MdKeyword), tc(config.MdString),
tc(config.MdNumber), tc(config.MdComment),
)
}
ec.SetTheme = func(name string) error {
return ui.ApplyTheme(name)
}
ec.ListThemes = func() []string {
return ui.ListThemes()
}
// Skill context-injection (drives a new agent turn through the TUI).
ec.InjectSkillAsContext = func(skillName string) string {
skills := kitInstance.DiscoverSkillsForExtension()
for _, s := range skills {
if s.Name == skillName {
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
}
}
return fmt.Sprintf("skill not found: %s", skillName)
}
ec.InjectRawSkillAsContext = func(path string) string {
s, err := kitInstance.LoadSkillForExtension(path)
if err != "" {
return err
}
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
}
return ec
}
+341 -892
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -58,6 +58,7 @@ kit install github.com/mark3labs/kit/examples/extensions --local
| `project-rules.go` | Project-specific rules | Session data, file reading |
| `protected-paths.go` | Block dangerous operations | `OnToolCall` with blocking |
| `permission-gate.go` | Confirm destructive actions | `OnToolCall` with confirmation |
| `usage-budget.go` | Soft cost cap + per-turn report | `OnLLMUsage`, `SetState`/`GetState`, enriched `AgentEndEvent` |
### Tools & Commands
+56 -4
View File
@@ -13,7 +13,7 @@ import (
// without panicking and properly guards nil ctx calls.
func TestSubagentMonitor_SessionStart(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
harness.LoadFile("./subagent-monitor.go")
// Emit SessionStart - should not panic even with nil ctx functions
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
@@ -26,7 +26,7 @@ func TestSubagentMonitor_SessionStart(t *testing.T) {
// creates entries and emits widget updates.
func TestSubagentMonitor_SubagentLifecycle(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
harness.LoadFile("./subagent-monitor.go")
// Start session
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
@@ -84,7 +84,7 @@ func TestSubagentMonitor_SubagentLifecycle(t *testing.T) {
// TestSubagentMonitor_MultipleSubagents verifies multiple parallel subagents.
func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
harness.LoadFile("./subagent-monitor.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
@@ -130,11 +130,63 @@ func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
time.Sleep(100 * time.Millisecond)
}
// TestSubagentMonitor_ConcurrentSubagents verifies no panics when multiple
// subagents emit events concurrently from different goroutines.
func TestSubagentMonitor_ConcurrentSubagents(t *testing.T) {
harness := test.New(t)
harness.LoadFile("./subagent-monitor.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
// Start 5 subagents concurrently
done := make(chan struct{}, 5)
for i := range 5 {
go func(idx int) {
defer func() { done <- struct{}{} }()
callID := fmt.Sprintf("concurrent-%d", idx)
task := fmt.Sprintf("concurrent task %d", idx)
_, _ = harness.Emit(extensions.SubagentStartEvent{
ToolCallID: callID,
Task: task,
})
// Emit many chunks rapidly
for j := range 20 {
_, _ = harness.Emit(extensions.SubagentChunkEvent{
ToolCallID: callID,
Task: task,
ChunkType: "text",
Content: fmt.Sprintf("agent %d chunk %d", idx, j),
})
}
_, _ = harness.Emit(extensions.SubagentEndEvent{
ToolCallID: callID,
Task: task,
Response: "done",
})
}(i)
}
// Wait for all goroutines
for range 5 {
<-done
}
// Allow any final processing
time.Sleep(200 * time.Millisecond)
}
// TestSubagentMonitor_SessionShutdown verifies shutdown doesn't panic
// even with nil ctx functions.
func TestSubagentMonitor_SessionShutdown(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
harness.LoadFile("./subagent-monitor.go")
// Start then shutdown
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
+87
View File
@@ -0,0 +1,87 @@
//go:build ignore
package main
import (
"fmt"
"strconv"
"kit/ext"
)
// Init demonstrates the three primitives added in issue #53:
//
// 1. api.OnLLMUsage(...) — per-LLM-call usage callback with token + cost
// deltas. Use this for budget enforcement that reacts between calls
// within a single agent turn, rather than only at turn boundaries.
//
// 2. ctx.SetState / ctx.GetState / ctx.DeleteState / ctx.ListState —
// last-write-wins, session-scoped key-value store backed by a sidecar
// file. Use this for snapshot state (current value of X) instead of
// ctx.AppendEntry, which is append-only and bloats branch reads.
//
// 3. ext.AgentEndEvent.ToolCallCount / .ToolNames / .LLMCallCount /
// .InputTokensDelta / .OutputTokensDelta / .CostDelta / .DurationMs —
// per-turn aggregates so observer extensions don't need to maintain
// parallel bookkeeping.
//
// Together these support a simple soft-budget cap: warn when the
// cumulative cost in this session exceeds a threshold, and print a
// per-turn report on AgentEnd.
//
// Usage: kit -e examples/extensions/usage-budget.go
func Init(api ext.API) {
const warnAtKey = "usage-budget:warn-at-usd"
// 1. Print per-LLM-call usage with provider, model, and cost.
api.OnLLMUsage(func(e ext.LLMUsageEvent, ctx ext.Context) {
ctx.Print(fmt.Sprintf(
"[usage] step=%d %s/%s tokens=↑%d ↓%d cache=↑%d/↓%d cost=$%.4f (%s)",
e.StepNumber, e.Provider, e.Model,
e.InputTokens, e.OutputTokens,
e.CacheWriteTokens, e.CacheReadTokens,
e.Cost, e.FinishReason,
))
// 2. Persist running total in last-write-wins state.
current := 0.0
if raw, ok := ctx.GetState("usage-budget:total-cost"); ok {
current, _ = strconv.ParseFloat(raw, 64)
}
current += e.Cost
ctx.SetState("usage-budget:total-cost", strconv.FormatFloat(current, 'f', 6, 64))
// Soft warn-at threshold (configurable via state).
warnAt := 0.50
if raw, ok := ctx.GetState(warnAtKey); ok {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
warnAt = v
}
}
if current > warnAt {
ctx.PrintError(fmt.Sprintf(
"[usage] session cost $%.4f exceeds soft cap $%.2f",
current, warnAt,
))
}
})
// 3. Print a per-turn summary using the enriched AgentEndEvent.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
ctx.Print(fmt.Sprintf(
"[turn] stop=%s tools=%d llm-calls=%d tokens=↑%d ↓%d cost=$%.4f duration=%dms",
e.StopReason, e.ToolCallCount, e.LLMCallCount,
e.InputTokensDelta, e.OutputTokensDelta, e.CostDelta, e.DurationMs,
))
if len(e.ToolNames) > 0 {
ctx.Print(fmt.Sprintf("[turn] tool order: %v", e.ToolNames))
}
})
// Bootstrap default soft cap once per session.
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
if _, ok := ctx.GetState(warnAtKey); !ok {
ctx.SetState(warnAtKey, "0.50")
}
})
}
+10
View File
@@ -42,4 +42,14 @@ defer host.Close()
response, err := host.Prompt(ctx, "Hello!")
```
Or use the functional-options constructor for quick setups (streaming defaults on):
```go
host, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithSystemPrompt("You are a helpful assistant."),
kit.Ephemeral(),
)
```
See the [SDK README](../../pkg/kit/README.md) for the full API reference.
+1 -1
View File
@@ -62,7 +62,7 @@ func main() {
}
})
// Subscribe to streaming chunks.
host3.OnStreaming(func(e kit.MessageUpdateEvent) {
host3.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk)
})
+59 -56
View File
@@ -1,32 +1,34 @@
module github.com/mark3labs/kit
go 1.26.2
go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.5
charm.land/fantasy v0.17.2
charm.land/bubbletea/v2 v2.0.7
charm.land/fantasy v0.25.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.3
github.com/alecthomas/chroma/v2 v2.23.1
github.com/alecthomas/chroma/v2 v2.26.1
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f
github.com/charmbracelet/x/editor v0.2.0
github.com/clipperhouse/displaywidth v0.11.0
github.com/clipperhouse/uax29/v2 v2.7.0
github.com/coder/acp-go-sdk v0.6.3
github.com/fsnotify/fsnotify v1.9.0
github.com/coder/acp-go-sdk v0.13.5
github.com/fsnotify/fsnotify v1.10.1
github.com/indaco/herald v0.13.0
github.com/indaco/herald-md v0.3.0
github.com/mark3labs/mcp-go v0.48.0
github.com/mark3labs/mcp-go v0.54.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/traefik/yaegi v0.16.1
golang.org/x/term v0.42.0
golang.org/x/image v0.41.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -35,68 +37,69 @@ require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.8 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.19 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.18 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 // indirect
github.com/aws/smithy-go v1.26.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kaptinlin/go-i18n v0.4.0 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
github.com/kaptinlin/jsonschema v0.7.7 // indirect
github.com/kaptinlin/messageformat-go v0.4.20 // indirect
github.com/kaptinlin/go-i18n v0.4.5 // indirect
github.com/kaptinlin/jsonpointer v0.4.25 // indirect
github.com/kaptinlin/jsonschema v0.7.13 // indirect
github.com/kaptinlin/messageformat-go v0.6.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.3.0 // indirect
github.com/muesli/mango-pflag v0.2.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
@@ -104,21 +107,21 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.275.0 // indirect
google.golang.org/genai v1.54.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/api v0.282.0 // indirect
google.golang.org/genai v1.58.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
@@ -129,13 +132,13 @@ require (
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/pflag v1.0.10
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0
)
+193 -114
View File
@@ -1,13 +1,15 @@
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.5 h1:TQlLFqxo39AAHSVuOhJ5D3nH7O9Nk8JGinsfWQ4y1U4=
charm.land/bubbletea/v2 v2.0.5/go.mod h1:dvbsYZD+MHkdIZl+Z67D212hEvB+GII2tfH8f9SnoDw=
charm.land/fantasy v0.17.2 h1:ojTMufMxY/PVH7TzYUxht2SVkvD90iCTJfmPR6c8BR8=
charm.land/fantasy v0.17.2/go.mod h1:V9cCIUMZB9g3Bq40aKEY8xBNzDd48EdfHp2OMS0uzWs=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
@@ -16,60 +18,81 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Rhymond/go-money v1.0.15/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/ardanlabs/jinja v1.2.0/go.mod h1:aXXzlJfjA+T3XNKA/YT5ZtDq2VJxt5a5siZ8cl9B35Q=
github.com/ardanlabs/kronk v1.25.2/go.mod h1:b5Gg4jDqvHDklkeHNB8+7treZRxUiCFsV65zphrTloY=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk=
github.com/aws/aws-sdk-go-v2 v1.41.8/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.19 h1:qRhIJMbevHUvIE7X4TK8N8zye5+5AhapcslPrvB+qKE=
github.com/aws/aws-sdk-go-v2/config v1.32.19/go.mod h1:RbJ24nfoya63+Mf5VI+CGCGk9vEdv28xPeii+gojRYs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.18 h1:GcXQz2M/0ZvMo0v5DakUqbDBeBM1ZNaivkolEF4Esgw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.18/go.mod h1:sHJ06tMGcD3ZpmMyJqV+VBsGilhSIZPIN+ZFy5Dg0C4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 h1:FQm5ApnyzkuJdXLGskPce83CK1CQKC4RUnIHKVe4BU4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24/go.mod h1:JsC7dqQc55MlZ5mvNsDMMge71u8pVcSzU3RNz2h/5yQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH3RdWKWqHULlTh7zHwb35Womf74=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 h1:yQo3eZ5qFaL1sJWqs1nL6j3yPHA2/R7c6tQ4T+0IO10=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0/go.mod h1:3Zzou41Qt/ueXfIzHvTEjDNuR5IjCUBVF01SNhrt1e8=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 h1:ApLTFdAZfDhZSiY5uskwECKHkSNNF83y2Ru2r7SezWA=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18/go.mod h1:A9K9qx2l6nK89hp+a350FdGfRkrkH5HdiEjHbiy/Q/c=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 h1:4VD7TIZOGzehrgQ8vDE+1c6BQW4ErZPGY8ohZT5LXEE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1/go.mod h1:er0SFJfdV89Rit5hIJu/EXtv+qC2XMnxoksLmcUFkqM=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 h1:XKnxlM4KZH1gktcsh3zSWc7GW4KivEv/OkifmHOhCUY=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2/go.mod h1:KJYmkQaFB3SUW2j3aBkPsxNmAb4ZsSOvbvCpuxzHJA0=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
@@ -86,8 +109,8 @@ github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdR
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e h1:O5hZFj55wZQWxMiRtQLa3uLKhZGZGS/j8M3OXinQlrw=
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f h1:vKsPSlO4g4jKfJ9enESgNZ45BkbHngTIq3UxNOzic74=
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
@@ -98,14 +121,14 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913 h1:6F/6bu5nBLjodsvaU5xAszTaxtHrDU5UiJarpMPZj48=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a h1:aVvnksCVgxB2igk7jERL9ARIkbDXccp1gXCFqhGlamQ=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913 h1:RiZFY92Ug9iz1CenzxSSQla2Z3WflsR7bIuXq40JlpU=
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d h1:RxcAR+vJCoD8QqT1cqLtkQKw+1cqvjqnu5IpPqYzPco=
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
@@ -120,36 +143,45 @@ github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcO
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk=
github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw=
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dromara/carbon/v2 v2.6.16/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eliben/go-sentencepiece v0.6.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -163,38 +195,53 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72/go.mod h1:Vn+BBgKQHVQYdVQ4NZDICE1Brb+JfaONyDHr3q07oQc=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hybridgroup/yzma v1.13.0/go.mod h1:zrzMgv/KVQz23+s6l16b+vJ+9uJVBdWtGcGkwRTMeiQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/indaco/herald v0.13.0 h1:+xVG9Fx5NpuWhwku/9IlRL6I009NnX4VUGKvlZHTRxU=
github.com/indaco/herald v0.13.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8M=
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
github.com/kaptinlin/go-i18n v0.4.0 h1:i7L3U2yurg+xhokITtJ0k+mjHnXqkoyz8ju5Wb7W8Oc=
github.com/kaptinlin/go-i18n v0.4.0/go.mod h1:njA6x0+4MWGcLWT0KLrwekhRPmze1Hnstf2+VJFzwpM=
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
github.com/kaptinlin/jsonschema v0.7.7 h1:41BlQJ9dskH0oE5DSzBUrl/w4JQYIr6N6L0B5GNyDoM=
github.com/kaptinlin/jsonschema v0.7.7/go.mod h1:rKjWfyySHSxAD7Li2ctYkPlOu960igoKBvZ2ADRtd5Q=
github.com/kaptinlin/messageformat-go v0.4.20 h1:a0ufTd5liiUubIGeGxpSTnNS8ZSrN4DV01/wGFmfzMs=
github.com/kaptinlin/messageformat-go v0.4.20/go.mod h1:FqdEPfQLkqVBX7OBRMPgYwUPvKYJohFD9Ok1BMzCfIo=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
github.com/kaptinlin/jsonpointer v0.4.25 h1:iJ197e8n+WwqaqBsa53FqG3rPJCg5oijyFXEXNWWC3E=
github.com/kaptinlin/jsonpointer v0.4.25/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWswQEFvU=
github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -203,12 +250,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw=
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -223,14 +272,19 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -238,6 +292,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -249,13 +307,14 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
@@ -266,67 +325,87 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/genai v1.54.0 h1:ZQCa70WMTJDI11FdqWCzGvZ5PanpcpfoO6jl/lrSnGU=
google.golang.org/genai v1.54.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U=
google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+67 -8
View File
@@ -61,6 +61,12 @@ func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.
return acp.AuthenticateResponse{}, nil
}
// Logout handles logout requests. Kit doesn't require auth for local stdio
// usage, so this is a no-op.
func (a *Agent) Logout(_ context.Context, _ acp.LogoutRequest) (acp.LogoutResponse, error) {
return acp.LogoutResponse{}, nil
}
// Initialize negotiates capabilities with the ACP client.
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
@@ -177,22 +183,75 @@ func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (
return acp.SetSessionModeResponse{}, nil
}
// SetSessionModel changes the active model for a session.
func (a *Agent) SetSessionModel(ctx context.Context, params acp.SetSessionModelRequest) (acp.SetSessionModelResponse, error) {
// ListSessions returns an empty session list. Kit doesn't persist sessions
// across restarts in ACP mode, so this is effectively a no-op.
func (a *Agent) ListSessions(_ context.Context, _ acp.ListSessionsRequest) (acp.ListSessionsResponse, error) {
return acp.ListSessionsResponse{
Sessions: []acp.SessionInfo{},
}, nil
}
// CloseSession cancels any ongoing work for the session and frees its resources.
func (a *Agent) CloseSession(_ context.Context, params acp.CloseSessionRequest) (acp.CloseSessionResponse, error) {
sessionID := string(params.SessionId)
sess, ok := a.registry.get(sessionID)
if !ok {
return acp.SetSessionModelResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
return acp.CloseSessionResponse{}, nil
}
modelID := string(params.ModelId)
log.Debug("acp: set_session_model", "session", sessionID, "model", modelID)
log.Debug("acp: close session", "session", sessionID)
sess.cancelPrompt()
a.registry.remove(sessionID)
return acp.CloseSessionResponse{}, nil
}
if err := sess.kit.SetModel(ctx, modelID); err != nil {
return acp.SetSessionModelResponse{}, fmt.Errorf("set model: %w", err)
// ResumeSession is not supported — Kit doesn't persist sessions across
// restarts in ACP mode. Clients should use NewSession instead.
func (a *Agent) ResumeSession(_ context.Context, _ acp.ResumeSessionRequest) (acp.ResumeSessionResponse, error) {
return acp.ResumeSessionResponse{}, fmt.Errorf("resume session not supported")
}
// SetSessionConfigOption handles session configuration changes. Currently
// supports the "model" config option to change the active model for a session.
func (a *Agent) SetSessionConfigOption(ctx context.Context, params acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) {
// Extract session ID and config ID from whichever variant is present.
var sessionID string
var configID string
var value string
switch {
case params.ValueId != nil:
sessionID = string(params.ValueId.SessionId)
configID = string(params.ValueId.ConfigId)
value = string(params.ValueId.Value)
case params.Boolean != nil:
sessionID = string(params.Boolean.SessionId)
configID = string(params.Boolean.ConfigId)
// Boolean config options are not used for model selection.
log.Debug("acp: set_session_config_option (boolean)", "session", sessionID, "config", configID, "value", params.Boolean.Value)
return acp.SetSessionConfigOptionResponse{}, nil
default:
return acp.SetSessionConfigOptionResponse{}, acp.NewInvalidParams("unsupported config option variant")
}
return acp.SetSessionModelResponse{}, nil
sess, ok := a.registry.get(sessionID)
if !ok {
return acp.SetSessionConfigOptionResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
}
log.Debug("acp: set_session_config_option", "session", sessionID, "config", configID, "value", value)
// Handle known config options.
switch configID {
case "model":
if err := sess.kit.SetModel(ctx, value); err != nil {
return acp.SetSessionConfigOptionResponse{}, fmt.Errorf("set model: %w", err)
}
default:
log.Debug("acp: unknown config option", "config", configID)
}
return acp.SetSessionConfigOptionResponse{}, nil
}
// ---------------------------------------------------------------------------
+86 -168
View File
@@ -7,7 +7,9 @@ import (
"sync"
"github.com/charmbracelet/log"
"github.com/spf13/viper"
"github.com/mark3labs/kit/internal/extbridge"
"github.com/mark3labs/kit/internal/extensions"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -37,10 +39,21 @@ func newSessionRegistry() *sessionRegistry {
// given working directory. The Kit-generated session ID is used as the ACP
// session ID so the mapping is 1:1.
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
// Each ACP session gets its own isolated config store (CLI is left nil) so
// per-session SetModel / SetThinkingLevel calls cannot race or bleed across
// the sessionRegistry. We seed the relevant root-command flag values from
// the process-global store (which cobra populated from flags) so launching
// `kit acp -m <model> [--thinking-level ...] [--provider-url ...]` is still
// honored; .kit.yml and KIT_* env vars are loaded per session by kit.New.
streamOn := true
kitInstance, err := kit.New(ctx, &kit.Options{
SessionDir: cwd,
Quiet: true,
Streaming: true,
SessionDir: cwd,
Quiet: true,
Streaming: &streamOn,
Model: viper.GetString("model"),
ThinkingLevel: viper.GetString("thinking-level"),
ProviderURL: viper.GetString("provider-url"),
ProviderAPIKey: viper.GetString("provider-api-key"),
})
if err != nil {
// Provide actionable guidance for provider auth errors, which are
@@ -60,142 +73,70 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
// Wire extension context with headless implementations so extensions
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
// become no-ops or return cancelled; all data/model/tool APIs work
// identically to interactive mode.
// become no-ops or return cancelled; all data/model/tool APIs come from
// extbridge.BaseContext and work identically to interactive mode.
if kitInstance.Extensions().HasExtensions() {
kitInstance.Extensions().SetContext(extensions.Context{
SessionID: sessionID,
CWD: cwd,
Model: kitInstance.GetModelString(),
Interactive: false,
// Use a background context for subagent spawns: the create() ctx is
// request-scoped and may be cancelled before extensions spawn anything.
ec := extbridge.BaseContext(context.Background(), kitInstance)
// Output — route through structured logger.
Print: func(text string) { log.Debug("extension: print", "text", text) },
PrintInfo: func(text string) { log.Info("extension: info", "text", text) },
PrintError: func(text string) { log.Error("extension: error", "text", text) },
PrintBlock: func(opts extensions.PrintBlockOpts) {
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
},
ec.SessionID = sessionID
ec.CWD = cwd
ec.Model = kitInstance.GetModelString()
ec.Interactive = false
// Message injection — no-ops for now; ACP clients drive prompts.
SendMessage: func(string) {},
CancelAndSend: func(string) {},
Exit: func() {},
// Output — route through structured logger.
ec.Print = func(text string) { log.Debug("extension: print", "text", text) }
ec.PrintInfo = func(text string) { log.Info("extension: info", "text", text) }
ec.PrintError = func(text string) { log.Error("extension: error", "text", text) }
ec.PrintBlock = func(opts extensions.PrintBlockOpts) {
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
}
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
SetWidget: func(extensions.WidgetConfig) {},
RemoveWidget: func(string) {},
SetHeader: func(extensions.HeaderFooterConfig) {},
RemoveHeader: func() {},
SetFooter: func(extensions.HeaderFooterConfig) {},
RemoveFooter: func() {},
SetEditor: func(extensions.EditorConfig) {},
ResetEditor: func() {},
SetEditorText: func(string) {},
SetUIVisibility: func(extensions.UIVisibility) {},
SetStatus: func(string, string, int) {},
RemoveStatus: func(string) {},
// Message injection — no-ops for now; ACP clients drive prompts.
ec.SendMessage = func(string) {}
ec.CancelAndSend = func(string) {}
ec.Exit = func() {}
// Interactive prompts — return cancelled (no user to prompt).
PromptSelect: func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
return extensions.PromptSelectResult{Cancelled: true}
},
PromptConfirm: func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
return extensions.PromptConfirmResult{Cancelled: true}
},
PromptInput: func(extensions.PromptInputConfig) extensions.PromptInputResult {
return extensions.PromptInputResult{Cancelled: true}
},
ShowOverlay: func(extensions.OverlayConfig) extensions.OverlayResult {
return extensions.OverlayResult{Cancelled: true, Index: -1}
},
SuspendTUI: func(callback func()) error { callback(); return nil },
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
ec.SetWidget = func(extensions.WidgetConfig) {}
ec.RemoveWidget = func(string) {}
ec.SetHeader = func(extensions.HeaderFooterConfig) {}
ec.RemoveHeader = func() {}
ec.SetFooter = func(extensions.HeaderFooterConfig) {}
ec.RemoveFooter = func() {}
ec.SetEditor = func(extensions.EditorConfig) {}
ec.ResetEditor = func() {}
ec.SetEditorText = func(string) {}
ec.SetUIVisibility = func(extensions.UIVisibility) {}
ec.SetStatus = func(string, string, int) {}
ec.RemoveStatus = func(string) {}
// Data access — delegate to Kit instance.
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
return extensions.ContextStats{
EstimatedTokens: s.EstimatedTokens,
ContextLimit: s.ContextLimit,
UsagePercent: s.UsagePercent,
MessageCount: s.MessageCount,
}
},
GetMessages: func() []extensions.SessionMessage { return kitInstance.Extensions().GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionPath() },
AppendEntry: func(entryType, data string) (string, error) {
return kitInstance.Extensions().AppendEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.Extensions().GetEntries(entryType)
},
// Interactive prompts — return cancelled (no user to prompt).
ec.PromptSelect = func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
return extensions.PromptSelectResult{Cancelled: true}
}
ec.PromptConfirm = func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
return extensions.PromptConfirmResult{Cancelled: true}
}
ec.PromptInput = func(extensions.PromptInputConfig) extensions.PromptInputResult {
return extensions.PromptInputResult{Cancelled: true}
}
ec.ShowOverlay = func(extensions.OverlayConfig) extensions.OverlayResult {
return extensions.OverlayResult{Cancelled: true, Index: -1}
}
ec.SuspendTUI = func(callback func()) error { callback(); return nil }
// Options, model, and tool management.
GetOption: func(name string) string { return kitInstance.Extensions().GetOption(name) },
SetOption: func(name, value string) { kitInstance.Extensions().SetOption(name, value) },
SetModel: func(modelString string) error {
previousModel := kitInstance.Extensions().GetContext().Model
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
return err
}
kitInstance.Extensions().UpdateContextModel(modelString)
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry { return kitInstance.GetAvailableModels() },
EmitCustomEvent: func(name, data string) { kitInstance.Extensions().EmitCustomEvent(name, data) },
GetAllTools: func() []extensions.ToolInfo { return kitInstance.Extensions().GetToolInfos() },
SetActiveTools: func(names []string) { kitInstance.Extensions().SetActiveTools(names) },
// Render — fall back to logging.
ec.RenderMessage = func(name, content string) {
renderer := kitInstance.Extensions().GetMessageRenderer(name)
if renderer != nil && renderer.Render != nil {
content = renderer.Render(content, 80)
}
log.Info("extension: message", "renderer", name, "content", content)
}
// LLM completions and subagents.
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
},
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
sdkCfg := kit.SubagentConfig{
Prompt: config.Prompt,
Model: config.Model,
SystemPrompt: config.SystemPrompt,
Timeout: config.Timeout,
NoSession: config.NoSession,
}
if config.OnEvent != nil {
sdkCfg.OnEvent = func(e kit.Event) {
se := sdkEventToSubagentEvent(e)
if se.Type != "" {
config.OnEvent(se)
}
}
}
result, err := kitInstance.Subagent(context.Background(), sdkCfg)
if result == nil {
return nil, &extensions.SubagentResult{Error: err}, err
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
if result.Usage != nil {
extResult.Usage = &extensions.SubagentUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
}
}
return nil, extResult, err
},
// Render — fall back to logging.
RenderMessage: func(name, content string) {
renderer := kitInstance.Extensions().GetMessageRenderer(name)
if renderer != nil && renderer.Render != nil {
content = renderer.Render(content, 80)
}
log.Info("extension: message", "renderer", name, "content", content)
},
ReloadExtensions: func() error { return kitInstance.Extensions().Reload() },
})
kitInstance.Extensions().SetContext(ec)
kitInstance.Extensions().EmitSessionStart()
}
@@ -232,6 +173,20 @@ func (r *sessionRegistry) closeAll() {
}
}
// remove closes and removes a single session by ID.
func (r *sessionRegistry) remove(sessionID string) {
r.mu.Lock()
defer r.mu.Unlock()
sess, ok := r.sessions[sessionID]
if !ok {
return
}
if sess.kit != nil {
_ = sess.kit.Close()
}
delete(r.sessions, sessionID)
}
// cancelPrompt cancels the current prompt for a session, if any.
func (s *acpSession) cancelPrompt() {
s.cancelMu.Lock()
@@ -255,40 +210,3 @@ func (s *acpSession) clearCancel() {
defer s.cancelMu.Unlock()
s.cancelFn = nil
}
// sdkEventToSubagentEvent converts an SDK event to an extension SubagentEvent.
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
switch ev := e.(type) {
case kit.MessageUpdateEvent:
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
case kit.ReasoningDeltaEvent:
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
case kit.ToolCallEvent:
return extensions.SubagentEvent{
Type: "tool_call", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
}
case kit.ToolExecutionStartEvent:
return extensions.SubagentEvent{
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolExecutionEndEvent:
return extensions.SubagentEvent{
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolResultEvent:
return extensions.SubagentEvent{
Type: "tool_result", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
ToolResult: ev.Result, IsError: ev.IsError,
}
case kit.TurnStartEvent:
return extensions.SubagentEvent{Type: "turn_start"}
case kit.TurnEndEvent:
return extensions.SubagentEvent{Type: "turn_end"}
default:
return extensions.SubagentEvent{}
}
}
+370 -75
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"strings"
"sync"
"time"
"charm.land/fantasy"
@@ -58,6 +60,11 @@ type AgentConfig struct {
// loading (successfully or with error). The callback receives the server
// name, tool count, and any error. Called from the background goroutine.
OnMCPServerLoaded func(serverName string, toolCount int, err error)
// MCPTaskConfig configures task-augmented tools/call execution. The
// zero value preserves historical synchronous-only behaviour for any
// server that didn't advertise task support during initialize.
MCPTaskConfig tools.MCPTaskConfig
}
// ToolCallHandler is a function type for handling tool calls as they happen.
@@ -87,6 +94,19 @@ type ReasoningDeltaHandler func(delta string)
// Called when the last reasoning token has been processed, before text streaming starts.
type ReasoningCompleteHandler func()
// ToolCallStartHandler is a function type for handling the moment when the LLM
// begins generating tool call arguments. The tool name is known but the full
// argument JSON is still streaming.
type ToolCallStartHandler func(toolCallID, toolName string)
// ToolCallDeltaHandler is a function type for handling streamed fragments of
// tool call arguments as they arrive from the LLM.
type ToolCallDeltaHandler func(toolCallID, delta string)
// ToolCallEndHandler is a function type for handling the end of tool argument
// streaming, before the tool call is parsed and execution begins.
type ToolCallEndHandler func(toolCallID string)
// ToolOutputHandler is a function type for handling streaming tool output chunks.
// Used by tools like bash to stream output as it arrives rather than waiting
// for the command to complete. The isStderr flag indicates if the chunk
@@ -113,6 +133,76 @@ type StepMessagesHandler func(stepMessages []fantasy.Message)
// tracking during long-running tool-calling conversations.
type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64)
// StepStartHandler is called when a new LLM step begins within a turn.
type StepStartHandler func(stepNumber int)
// StepFinishHandler is called when a step completes with full context.
type StepFinishHandler func(stepNumber int, hasToolCalls bool, finishReason string, usage fantasy.Usage)
// TextStartHandler is called when the LLM begins generating text content.
type TextStartHandler func(id string)
// TextEndHandler is called when the LLM finishes generating text content.
type TextEndHandler func(id string)
// ReasoningStartHandler is called when the LLM begins reasoning/thinking.
type ReasoningStartHandler func(id string)
// WarningsHandler is called when the LLM provider returns warnings.
type WarningsHandler func(warnings []string)
// SourceHandler is called when the LLM references a source.
type SourceHandler func(sourceType, id, url, title string)
// StreamFinishHandler is called when a per-step LLM stream completes.
type StreamFinishHandler func(usage fantasy.Usage, finishReason string)
// ErrorHandler is called when an agent-level error occurs.
type ErrorHandler func(err error)
// RetryHandler is called when the LLM request is retried.
type RetryHandler func(attempt int, err error)
// PrepareStepHandler is called between steps to allow message modification.
// It receives the step number and current messages, and returns replacement
// messages (or nil to keep unchanged).
type PrepareStepHandler func(stepNumber int, messages []fantasy.Message) []fantasy.Message
// GenerateCallbacks consolidates all callback functions for
// GenerateWithCallbacks into a single struct, replacing what was previously
// 16+ positional callback parameters. New fields default to nil, so adding
// new callbacks does not break existing callers.
type GenerateCallbacks struct {
OnToolCall ToolCallHandler
OnToolExecution ToolExecutionHandler
OnToolResult ToolResultHandler
OnResponse ResponseHandler
OnToolCallContent ToolCallContentHandler
OnStreamingResponse StreamingResponseHandler
OnReasoningDelta ReasoningDeltaHandler
OnReasoningComplete ReasoningCompleteHandler
OnToolOutput ToolOutputHandler
OnStepMessages StepMessagesHandler
OnStepUsage StepUsageHandler
OnPasswordPrompt PasswordPromptHandler
OnToolCallStart ToolCallStartHandler
OnToolCallDelta ToolCallDeltaHandler
OnToolCallEnd ToolCallEndHandler
// New callbacks for previously unwired Fantasy lifecycle events.
OnStepStart StepStartHandler
OnStepFinish StepFinishHandler
OnTextStart TextStartHandler
OnTextEnd TextEndHandler
OnReasoningStart ReasoningStartHandler
OnWarnings WarningsHandler
OnSource SourceHandler
OnStreamFinish StreamFinishHandler
OnError ErrorHandler
OnRetry RetryHandler
OnPrepareStep PrepareStepHandler
}
// Agent represents an AI agent with core tool integration using the LLM library.
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
// AgentTool implementations — no MCP layer, no serialization overhead.
@@ -147,11 +237,21 @@ type Agent struct {
authHandler tools.MCPAuthHandler
tokenStoreFactory tools.TokenStoreFactory
// mcpTaskConfig is stored from AgentConfig so AddMCPServer() can
// propagate it to a lazily-created MCPToolManager.
mcpTaskConfig tools.MCPTaskConfig
// mcpReady is closed when background MCP tool loading completes (success
// or failure). nil when no MCP servers are configured.
mcpReady chan struct{}
// mcpErr holds any error from background MCP loading.
mcpErr error
// promptMu serializes runtime updates to systemPrompt and the
// accompanying fantasy agent rebuild so concurrent SetSystemPrompt
// callers (e.g. Kit.applyComposedSystemPrompt invoked from multiple
// goroutines) don't race on a.systemPrompt / a.fantasyAgent.
promptMu sync.Mutex
}
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
@@ -245,6 +345,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
modelConfig: agentConfig.ModelConfig,
authHandler: agentConfig.AuthHandler,
tokenStoreFactory: agentConfig.TokenStoreFactory,
mcpTaskConfig: agentConfig.MCPTaskConfig,
}
// Start MCP tool loading in the background if servers are configured.
@@ -264,6 +365,8 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
if agentConfig.OnMCPServerLoaded != nil {
toolManager.SetOnServerLoaded(agentConfig.OnMCPServerLoaded)
}
// Apply task-augmented tool execution config (zero value = no-op).
toolManager.SetTaskConfig(agentConfig.MCPTaskConfig)
a.toolManager = toolManager
a.mcpReady = make(chan struct{})
@@ -410,23 +513,20 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
) (*GenerateWithLoopResult, error) {
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil, nil)
return a.GenerateWithCallbacks(ctx, messages, GenerateCallbacks{
OnToolCall: onToolCall,
OnToolExecution: onToolExecution,
OnToolResult: onToolResult,
OnResponse: onResponse,
OnToolCallContent: onToolCallContent,
})
}
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
// GenerateWithCallbacks processes messages using the agent with streaming and callbacks.
// The agent handles the tool call loop internally. We map the rich callback system
// to kit's existing callback interface for UI integration.
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
onStreamingResponse StreamingResponseHandler,
onReasoningDelta ReasoningDeltaHandler,
onReasoningComplete ReasoningCompleteHandler,
onToolOutput ToolOutputHandler,
onStepMessages StepMessagesHandler,
onStepUsage StepUsageHandler,
onPasswordPrompt PasswordPromptHandler,
func (a *Agent) GenerateWithCallbacks(ctx context.Context, messages []fantasy.Message,
cb GenerateCallbacks,
) (*GenerateWithLoopResult, error) {
// Wait for background MCP tool loading to complete and rebuild the
@@ -435,13 +535,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
a.ensureMCPTools()
// Inject tool output handler into context for use by core tools (e.g., bash).
if onToolOutput != nil {
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
if cb.OnToolOutput != nil {
ctx = core.ContextWithToolOutputCallback(ctx, cb.OnToolOutput)
}
// Inject password prompt handler into context for use by bash tool.
if onPasswordPrompt != nil {
ctx = core.ContextWithPasswordPrompt(ctx, onPasswordPrompt)
if cb.OnPasswordPrompt != nil {
ctx = core.ContextWithPasswordPrompt(ctx, cb.OnPasswordPrompt)
}
// The agent requires the current user input as Prompt, with prior messages as history.
@@ -454,15 +554,25 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// This avoids type conflicts with provider-level options.
history = applyCacheControlToMessages(history)
// Track current tool call args for callbacks
var currentToolArgs string
// Track tool call args per-ToolCallID so parallel tool calls in a single
// step don't clobber each other. Without this, OnToolResult callbacks would
// all see the args of the last OnToolCall in the step. The mutex guards
// against the possibility that the underlying streaming layer dispatches
// callbacks from multiple goroutines.
toolCallArgs := make(map[string]string)
var toolCallArgsMu sync.Mutex
// Use the streaming path when streaming is enabled OR when any callbacks are
// provided. The agent only exposes tool/step callbacks on AgentStreamCall, so
// Stream is required to observe tool execution in real time. The non-streaming
// Generate path is reserved for the simple case with no callbacks at all.
hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil ||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
hasCallbacks := cb.OnToolCall != nil || cb.OnToolExecution != nil || cb.OnToolResult != nil ||
cb.OnToolCallContent != nil || cb.OnStreamingResponse != nil || cb.OnReasoningDelta != nil ||
cb.OnToolCallStart != nil || cb.OnToolCallDelta != nil || cb.OnToolCallEnd != nil ||
cb.OnStepStart != nil || cb.OnStepFinish != nil || cb.OnTextStart != nil ||
cb.OnTextEnd != nil || cb.OnReasoningStart != nil || cb.OnWarnings != nil ||
cb.OnSource != nil || cb.OnStreamFinish != nil || cb.OnError != nil ||
cb.OnRetry != nil || cb.OnPrepareStep != nil
if a.streamingEnabled || hasCallbacks {
// Track completed step messages so we can return partial results
@@ -471,9 +581,11 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// for every step that completed before the error occurred.
var completedStepMessages []fantasy.Message
// persistedCount tracks how many new messages (beyond the original
// input) were persisted incrementally via onStepMessages, so the
// input) were persisted incrementally via cb.OnStepMessages, so the
// caller can skip them during post-generation persistence.
var persistedCount int
// stepCounter tracks the current step number for StepStart/StepFinish events.
var stepCounter int
// Use the streaming agent
streamCall := fantasy.AgentStreamCall{
@@ -481,13 +593,73 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
Files: files,
Messages: history,
// Tool input streaming callbacks — fire during tool argument generation
OnToolInputStart: func(id, toolName string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnToolCallStart != nil {
cb.OnToolCallStart(id, toolName)
}
return nil
},
OnToolInputDelta: func(id, delta string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnToolCallDelta != nil {
cb.OnToolCallDelta(id, delta)
}
return nil
},
OnToolInputEnd: func(id string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnToolCallEnd != nil {
cb.OnToolCallEnd(id)
}
return nil
},
// Text start/end callbacks
OnTextStart: func(id string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnTextStart != nil {
cb.OnTextStart(id)
}
return nil
},
OnTextEnd: func(id string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnTextEnd != nil {
cb.OnTextEnd(id)
}
return nil
},
// Reasoning start callback
OnReasoningStart: func(id string, _ fantasy.ReasoningContent) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnReasoningStart != nil {
cb.OnReasoningStart(id)
}
return nil
},
// Reasoning/thinking streaming callback
OnReasoningDelta: func(id, delta string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if onReasoningDelta != nil {
onReasoningDelta(delta)
if cb.OnReasoningDelta != nil {
cb.OnReasoningDelta(delta)
}
return nil
},
@@ -497,8 +669,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
if onReasoningComplete != nil {
onReasoningComplete()
if cb.OnReasoningComplete != nil {
cb.OnReasoningComplete()
}
return nil
},
@@ -508,8 +680,64 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
if onStreamingResponse != nil {
onStreamingResponse(text)
if cb.OnStreamingResponse != nil {
cb.OnStreamingResponse(text)
}
return nil
},
// Warnings callback
OnWarnings: func(warnings []fantasy.CallWarning) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnWarnings != nil {
strs := make([]string, len(warnings))
for i, w := range warnings {
strs[i] = w.Message
}
cb.OnWarnings(strs)
}
return nil
},
// Source callback
OnSource: func(source fantasy.SourceContent) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnSource != nil {
cb.OnSource(string(source.SourceType), source.ID, source.URL, source.Title)
}
return nil
},
// Stream finish callback (per-step stream completion)
OnStreamFinish: func(usage fantasy.Usage, finishReason fantasy.FinishReason, _ fantasy.ProviderMetadata) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnStreamFinish != nil {
cb.OnStreamFinish(usage, string(finishReason))
}
return nil
},
// Error callback
OnError: func(err error) {
if cb.OnError != nil {
cb.OnError(err)
}
},
// Step start callback
OnStepStart: func(stepNumber int) error {
if ctx.Err() != nil {
return ctx.Err()
}
stepCounter = stepNumber
if cb.OnStepStart != nil {
cb.OnStepStart(stepNumber)
}
return nil
},
@@ -519,16 +747,18 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
currentToolArgs = tc.Input
toolCallArgsMu.Lock()
toolCallArgs[tc.ToolCallID] = tc.Input
toolCallArgsMu.Unlock()
// Notify about the tool call
if onToolCall != nil {
onToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
if cb.OnToolCall != nil {
cb.OnToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
}
// Notify tool execution starting
if onToolExecution != nil {
onToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
if cb.OnToolExecution != nil {
cb.OnToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
}
return nil
@@ -539,15 +769,22 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
// Look up the args recorded for this specific tool call. Delete
// the entry so the map doesn't accumulate across steps.
toolCallArgsMu.Lock()
args := toolCallArgs[tr.ToolCallID]
delete(toolCallArgs, tr.ToolCallID)
toolCallArgsMu.Unlock()
// Notify tool execution finished
if onToolExecution != nil {
onToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
if cb.OnToolExecution != nil {
cb.OnToolExecution(tr.ToolCallID, tr.ToolName, args, false)
}
if onToolResult != nil {
if cb.OnToolResult != nil {
// Extract result text and error status
resultText, isError := extractToolResultText(tr)
onToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
cb.OnToolResult(tr.ToolCallID, tr.ToolName, args, resultText, tr.ClientMetadata, isError)
}
return nil
@@ -561,8 +798,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Persist step messages incrementally so progress is saved
// as it happens rather than only at the end of the turn.
if onStepMessages != nil && len(step.Messages) > 0 {
onStepMessages(step.Messages)
if cb.OnStepMessages != nil && len(step.Messages) > 0 {
cb.OnStepMessages(step.Messages)
persistedCount += len(step.Messages)
}
@@ -572,65 +809,88 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Check if step has text content alongside tool calls
text := step.Content.Text()
toolCalls := step.Content.ToolCalls()
if text != "" && len(toolCalls) > 0 && onToolCallContent != nil {
onToolCallContent(text)
if text != "" && len(toolCalls) > 0 && cb.OnToolCallContent != nil {
cb.OnToolCallContent(text)
}
// Emit step usage for real-time cost tracking
if onStepUsage != nil {
onStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
if cb.OnStepUsage != nil {
cb.OnStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
step.Usage.CacheReadTokens, step.Usage.CacheCreationTokens)
}
// Emit unified step finish event
if cb.OnStepFinish != nil {
cb.OnStepFinish(stepCounter, len(toolCalls) > 0, string(step.FinishReason), step.Usage)
}
return nil
},
}
// If a steer channel is attached to the context, wire up a
// PrepareStep function that drains the channel between steps
// and injects pending steer messages as user messages before
// the next LLM call. This enables graceful mid-turn steering
// without cancelling in-progress tool execution.
if steerCh := steerChFromContext(ctx); steerCh != nil {
onConsumed := steerConsumedFromContext(ctx)
// Always wire up PrepareStep to handle both steering and the
// OnPrepareStep hook. Steering drains its channel first, then
// OnPrepareStep hooks run against the (possibly already steered)
// messages.
steerCh := steerChFromContext(ctx)
onConsumed := steerConsumedFromContext(ctx)
hasSteering := steerCh != nil
hasPrepareStepHook := cb.OnPrepareStep != nil
if hasSteering || hasPrepareStepHook {
streamCall.PrepareStep = func(
stepCtx context.Context,
opts fantasy.PrepareStepFunctionOptions,
) (context.Context, fantasy.PrepareStepResult, error) {
// Drain all pending steer messages (non-blocking).
var steered []SteerMessage
for {
select {
case msg := <-steerCh:
steered = append(steered, msg)
default:
goto done
}
}
done:
result := fantasy.PrepareStepResult{
Model: opts.Model,
Messages: opts.Messages,
}
if len(steered) > 0 {
// Inject each steer message as a user message so the
// LLM sees the redirection on the next step.
for _, sm := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(sm.Text, sm.Files...))
// Phase 1: Drain steering channel (if present).
if hasSteering {
var steered []SteerMessage
for {
select {
case msg := <-steerCh:
steered = append(steered, msg)
default:
goto done
}
}
// Notify that steer messages were consumed.
if onConsumed != nil {
onConsumed(len(steered))
done:
if len(steered) > 0 {
for _, sm := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(sm.Text, sm.Files...))
}
if onConsumed != nil {
onConsumed(len(steered))
}
}
}
// Phase 2: Run OnPrepareStep hook (if registered).
if hasPrepareStepHook {
if replacement := cb.OnPrepareStep(opts.StepNumber, result.Messages); replacement != nil {
result.Messages = replacement
}
}
// Apply message-level cache control for Anthropic models.
// This avoids type conflicts with provider-level options.
result.Messages = applyCacheControlToMessages(result.Messages)
return stepCtx, result, nil
}
}
// Wire OnRetry callback if provided.
if cb.OnRetry != nil {
streamCall.OnRetry = func(err *fantasy.ProviderError, _ time.Duration) {
// Use the retry number from the error if available; Fantasy
// doesn't pass a counter directly, so we approximate with a
// counter incremented on each call.
cb.OnRetry(0, err)
}
}
result, err := a.fantasyAgent.Stream(ctx, streamCall)
if err != nil {
// On cancellation (or any error), return a partial result
@@ -656,8 +916,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// empty (e.g. reasoning-only responses) so the UI properly resets
// the stream component and avoids duplicate content on the next
// flush.
if onResponse != nil {
onResponse(result.Response.Content.Text())
if cb.OnResponse != nil {
cb.OnResponse(result.Response.Content.Text())
}
r := convertAgentResult(result, messages)
@@ -677,8 +937,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// For non-streaming, fire the response callback so callers can reset
// streaming state (see streaming path comment above).
if onResponse != nil {
onResponse(result.Response.Content.Text())
if cb.OnResponse != nil {
cb.OnResponse(result.Response.Content.Text())
}
return convertAgentResult(result, messages), nil
@@ -869,6 +1129,7 @@ func (a *Agent) AddMCPServer(ctx context.Context, name string, cfg config.MCPSer
if a.tokenStoreFactory != nil {
a.toolManager.SetTokenStoreFactory(a.tokenStoreFactory)
}
a.toolManager.SetTaskConfig(a.mcpTaskConfig)
a.toolManager.SetOnToolsChanged(func() {
a.rebuildFantasyAgent()
})
@@ -1025,6 +1286,40 @@ func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
}
// SetSystemPrompt updates the agent's system prompt and rebuilds the underlying
// fantasy agent so subsequent turns use the new prompt. Safe to call while the
// agent is idle; if invoked during an in-flight turn the new prompt takes
// effect on the next LLM call.
func (a *Agent) SetSystemPrompt(prompt string) {
a.promptMu.Lock()
defer a.promptMu.Unlock()
a.systemPrompt = prompt
a.rebuildFantasyAgent()
}
// GetSystemPrompt returns the agent's current system prompt.
func (a *Agent) GetSystemPrompt() string {
a.promptMu.Lock()
defer a.promptMu.Unlock()
return a.systemPrompt
}
// GetMaxTokens returns the effective max output tokens the agent currently
// sends to the LLM provider, after per-model defaults, right-sizing, and any
// Anthropic thinking-budget adjustments. Returns 0 when no ModelConfig is
// attached (e.g. early init) or when the provider suppresses the parameter
// (e.g. Codex OAuth), which allows callers to differentiate "default" from
// "explicitly capped".
func (a *Agent) GetMaxTokens() int {
if a.skipMaxOutputTokens {
return 0
}
if a.modelConfig == nil {
return 0
}
return a.modelConfig.MaxTokens
}
// Close closes the agent and cleans up resources.
// If MCP tools are still loading in the background, Close waits for them
// to finish before closing connections to avoid resource leaks.
@@ -0,0 +1,109 @@
package agent
import (
"context"
"sync"
"testing"
"charm.land/fantasy"
)
// fakeParallelAgent simulates a provider that emits two parallel tool_use
// blocks in a single step. It invokes the streaming callbacks in the order:
//
// OnToolCall(A) -> OnToolCall(B) -> OnToolResult(A) -> OnToolResult(B)
//
// Before the fix in #33 the agent-layer wrapper recorded a single
// `currentToolArgs` variable that was clobbered by the second OnToolCall, so
// both OnToolResult callbacks received B's args instead of their own.
type fakeParallelAgent struct {
calls []fantasy.ToolCallContent
results []fantasy.ToolResultContent
}
func (f *fakeParallelAgent) Generate(_ context.Context, _ fantasy.AgentCall) (*fantasy.AgentResult, error) {
return &fantasy.AgentResult{}, nil
}
func (f *fakeParallelAgent) Stream(_ context.Context, opts fantasy.AgentStreamCall) (*fantasy.AgentResult, error) {
for _, tc := range f.calls {
if opts.OnToolCall != nil {
if err := opts.OnToolCall(tc); err != nil {
return nil, err
}
}
}
for _, tr := range f.results {
if opts.OnToolResult != nil {
if err := opts.OnToolResult(tr); err != nil {
return nil, err
}
}
}
return &fantasy.AgentResult{}, nil
}
// TestGenerateWithCallbacks_ParallelToolArgs is the regression test for #33.
// It drives the streaming-callback wiring inside GenerateWithCallbacks with a
// fake fantasy.Agent that emits two parallel tool calls before either result.
// Each OnToolResult must receive the args of its own tool call (matched by
// ToolCallID), not the args of the last OnToolCall in the step.
func TestGenerateWithCallbacks_ParallelToolArgs(t *testing.T) {
t.Parallel()
argsA := `{"name":"scheduled_jobs"}`
argsB := `{"name":"gmail_trigger"}`
fake := &fakeParallelAgent{
calls: []fantasy.ToolCallContent{
{ToolCallID: "kit-A", ToolName: "load_skill", Input: argsA},
{ToolCallID: "kit-B", ToolName: "load_skill", Input: argsB},
},
results: []fantasy.ToolResultContent{
{ToolCallID: "kit-A", ToolName: "load_skill", Result: fantasy.ToolResultOutputContentText{Text: "ok-A"}},
{ToolCallID: "kit-B", ToolName: "load_skill", Result: fantasy.ToolResultOutputContentText{Text: "ok-B"}},
},
}
a := &Agent{
fantasyAgent: fake,
streamingEnabled: false, // exercise the "hasCallbacks" branch
}
var mu sync.Mutex
resultArgs := map[string]string{}
executionArgs := map[string]string{} // captured when running == false
cb := GenerateCallbacks{
OnToolExecution: func(id, _, args string, running bool) {
if running {
return
}
mu.Lock()
defer mu.Unlock()
executionArgs[id] = args
},
OnToolResult: func(id, _, args, _, _ string, _ bool) {
mu.Lock()
defer mu.Unlock()
resultArgs[id] = args
},
}
if _, err := a.GenerateWithCallbacks(context.Background(), nil, cb); err != nil {
t.Fatalf("GenerateWithCallbacks returned error: %v", err)
}
if got, want := resultArgs["kit-A"], argsA; got != want {
t.Errorf("OnToolResult for kit-A: args = %q, want %q", got, want)
}
if got, want := resultArgs["kit-B"], argsB; got != want {
t.Errorf("OnToolResult for kit-B: args = %q, want %q", got, want)
}
if got, want := executionArgs["kit-A"], argsA; got != want {
t.Errorf("OnToolExecution(finish) for kit-A: args = %q, want %q", got, want)
}
if got, want := executionArgs["kit-B"], argsB; got != want {
t.Errorf("OnToolExecution(finish) for kit-B: args = %q, want %q", got, want)
}
}
+3
View File
@@ -56,6 +56,8 @@ type AgentCreationOptions struct {
// OnMCPServerLoaded, if non-nil, is called when each MCP server finishes
// loading (successfully or with error). Called from the background goroutine.
OnMCPServerLoaded func(serverName string, toolCount int, err error)
// MCPTaskConfig configures task-augmented tools/call execution.
MCPTaskConfig tools.MCPTaskConfig
}
// CreateAgent creates an agent with optional spinner for Ollama models.
@@ -76,6 +78,7 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error
ToolWrapper: opts.ToolWrapper,
ExtraTools: opts.ExtraTools,
OnMCPServerLoaded: opts.OnMCPServerLoaded,
MCPTaskConfig: opts.MCPTaskConfig,
}
var agent *Agent
+28 -5
View File
@@ -9,12 +9,19 @@ import (
"github.com/mark3labs/kit/internal/tools"
)
// mcpExecutor is the subset of *tools.MCPToolManager that the adapter
// actually uses. Extracted as an interface so the adapter is unit-testable
// without constructing a full manager + connection pool.
type mcpExecutor interface {
ExecuteTool(ctx context.Context, prefixedName, inputJSON string) (*tools.MCPToolResult, error)
}
// mcpAgentTool adapts an tools.MCPTool to the fantasy.AgentTool interface.
// This keeps the fantasy dependency confined to the agent layer — the tools
// package is a pure MCP client library with no LLM framework dependency.
type mcpAgentTool struct {
tool tools.MCPTool
manager *tools.MCPToolManager
exec mcpExecutor
providerOptions fantasy.ProviderOptions
}
@@ -29,10 +36,26 @@ func (t *mcpAgentTool) Info() fantasy.ToolInfo {
}
// Run executes the MCP tool by delegating to the MCPToolManager.
//
// MCP-side failures (JSON-RPC protocol errors, transport failures, schema
// validation rejections from the server) are surfaced to the model as soft
// tool errors rather than escalated to a critical agent error. This matches
// the contract that native Kit tools follow via kit.ErrorResult(...) and
// lets the model self-correct (e.g. retry with a fixed argument shape) or
// give up gracefully rather than aborting the turn mid-run.
//
// Context cancellation is the one exception: if the caller cancelled the
// context the turn was aborted intentionally, so we propagate the ctx error
// to let the agent loop unwind cleanly.
func (t *mcpAgentTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
result, err := t.manager.ExecuteTool(ctx, t.tool.Name, call.Input)
result, err := t.exec.ExecuteTool(ctx, t.tool.Name, call.Input)
if err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("mcp tool execution failed: %w", err)
if ctxErr := ctx.Err(); ctxErr != nil {
return fantasy.ToolResponse{}, ctxErr
}
return fantasy.NewTextErrorResponse(
fmt.Sprintf("MCP tool %q failed: %s", t.tool.Name, err.Error()),
), nil
}
if result.IsError {
@@ -57,8 +80,8 @@ func mcpToolsToAgentTools(mcpTools []tools.MCPTool, manager *tools.MCPToolManage
agentTools := make([]fantasy.AgentTool, len(mcpTools))
for i, t := range mcpTools {
agentTools[i] = &mcpAgentTool{
tool: t,
manager: manager,
tool: t,
exec: manager,
}
}
return agentTools
+158
View File
@@ -0,0 +1,158 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/tools"
)
// stubExecutor lets each test script the (result, err) pair returned by
// ExecuteTool. The adapter holds an mcpExecutor interface, so this is the
// only seam the tests need.
type stubExecutor struct {
result *tools.MCPToolResult
err error
// called records the last invocation for assertion.
called bool
name string
input string
}
func (s *stubExecutor) ExecuteTool(_ context.Context, prefixedName, inputJSON string) (*tools.MCPToolResult, error) {
s.called = true
s.name = prefixedName
s.input = inputJSON
return s.result, s.err
}
func newMCPAgentTool(exec mcpExecutor, name string) *mcpAgentTool {
return &mcpAgentTool{
tool: tools.MCPTool{Name: name},
exec: exec,
}
}
// Manager-side Go errors (JSON-RPC protocol errors, transport failures,
// schema validation rejections from the MCP server) must be surfaced to
// the model as soft tool errors so the agent loop can keep going. Aborting
// the turn would discard all prior tool results — see issue #N.
func TestMCPAgentTool_RPCErrorBecomesSoftError(t *testing.T) {
exec := &stubExecutor{
err: errors.New("MCP error -32602: Invalid params: missing field \"task\""),
}
tool := newMCPAgentTool(exec, "pubmed__search")
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "pubmed__search",
Input: `{"query":"foo"}`,
})
if err != nil {
t.Fatalf("expected nil error (soft), got %v", err)
}
if !resp.IsError {
t.Fatalf("expected IsError=true, got false")
}
if !strings.Contains(resp.Content, "pubmed__search") {
t.Errorf("expected tool name in error content, got %q", resp.Content)
}
if !strings.Contains(resp.Content, "-32602") {
t.Errorf("expected underlying error text in content, got %q", resp.Content)
}
}
// Context cancellation is the one error that must remain critical: it
// means the caller intentionally aborted, and the agent loop needs to
// unwind cleanly rather than burning more steps.
func TestMCPAgentTool_CtxCancelStaysCritical(t *testing.T) {
exec := &stubExecutor{
// Real managers typically return ctx.Err() (or a wrapper) when the
// context is cancelled mid-call.
err: context.Canceled,
}
tool := newMCPAgentTool(exec, "slow__tool")
ctx, cancel := context.WithCancel(context.Background())
cancel()
resp, err := tool.Run(ctx, fantasy.ToolCall{Name: "slow__tool"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
if resp.IsError || resp.Content != "" {
t.Errorf("expected empty response on critical error, got IsError=%v Content=%q", resp.IsError, resp.Content)
}
}
// Deadline-exceeded behaves the same as cancellation: ctx.Err() is
// non-nil, so the adapter must propagate the critical error rather than
// converting the executor's error into a soft response.
func TestMCPAgentTool_CtxDeadlineStaysCritical(t *testing.T) {
exec := &stubExecutor{err: context.DeadlineExceeded}
tool := newMCPAgentTool(exec, "slow__tool")
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
resp, err := tool.Run(ctx, fantasy.ToolCall{Name: "slow__tool"})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
}
if resp.IsError || resp.Content != "" {
t.Errorf("expected empty response on critical error, got IsError=%v Content=%q", resp.IsError, resp.Content)
}
}
// Server-side soft errors (CallToolResult{ isError: true }) must continue
// to flow through as soft errors — this was the existing behavior and
// must not regress.
func TestMCPAgentTool_ServerIsErrorRemainsSoftError(t *testing.T) {
exec := &stubExecutor{
result: &tools.MCPToolResult{
IsError: true,
Content: "search service is rate limited; try again in 30s",
},
}
tool := newMCPAgentTool(exec, "pubmed__search")
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Name: "pubmed__search"})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if !resp.IsError {
t.Fatalf("expected IsError=true, got false")
}
if resp.Content != "search service is rate limited; try again in 30s" {
t.Errorf("expected pass-through content, got %q", resp.Content)
}
}
// Happy path: ordinary successful tool result is passed through unchanged.
func TestMCPAgentTool_SuccessIsPassthrough(t *testing.T) {
exec := &stubExecutor{
result: &tools.MCPToolResult{
IsError: false,
Content: `{"hits":3}`,
},
}
tool := newMCPAgentTool(exec, "pubmed__search")
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Name: "pubmed__search"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.IsError {
t.Fatalf("expected IsError=false")
}
if resp.Content != `{"hits":3}` {
t.Errorf("expected pass-through content, got %q", resp.Content)
}
}
+309 -40
View File
@@ -13,6 +13,7 @@ import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -70,14 +71,24 @@ type App struct {
rootCtx context.Context
rootCancel context.CancelFunc
// widgetUpdatePending is set to true when a WidgetUpdateEvent has been
// sent to the TUI but not yet consumed by its event loop. While the flag
// is set, subsequent NotifyWidgetUpdate calls are coalesced (dropped) to
// prevent fast extension tickers from flooding the BubbleTea mailbox with
// redundant re-render triggers. The flag is cleared after a short debounce
// (~1 frame) so new updates are always let through once the TUI has had a
// chance to process the pending event.
widgetUpdatePending atomic.Bool
// widgetUpdatePending is set to true while a WidgetUpdateEvent burst is
// being coalesced. The leading edge fires immediately; subsequent calls
// within the debounce window set widgetUpdateTrailing so a final event
// is delivered with the latest runner state at the end of the window.
// Without the trailing send, a rapid SetWidget→RemoveWidget pair (e.g.
// SubagentEnd pushing a final frame then removing the widget) would let
// the second call get silently dropped, leaving the TUI's layout stuck
// on the pre-removal widget height — visible as empty rows below the
// status bar after the widget disappears.
widgetUpdatePending atomic.Bool
widgetUpdateTrailing atomic.Bool
// steerDrainFn is the test seam used by releaseBusyAfterCompact to pull
// any steer messages that arrived during compaction. In production it is
// nil and the helper falls back to a.opts.Kit.DrainSteer(); tests that
// need to exercise the steer-drain path without standing up a full
// *kit.Kit can set this field directly to inject fake items.
steerDrainFn func() []queueItem
}
// New creates a new App with the provided options and pre-loaded messages.
@@ -333,6 +344,90 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
}
}
// PopLastUserMessage truncates the tree session back to the parent of the
// most recent user message on the current branch, syncs the in-memory
// message store, and returns the user prompt text plus any image file
// parts so the caller can resubmit via Run/RunWithFiles.
//
// This is the building block for /retry: the user message and any orphaned
// assistant/tool entries produced by a failed turn become unreachable on
// the current branch (they remain in the session file under a different
// leaf) and are excluded from the next LLM context.
//
// Returns an error when:
// - the agent is currently working (busy)
// - the app has been closed
// - no tree session is active (sessions disabled via --no-session)
// - no user message exists on the current branch
//
// Satisfies ui.AppController.
func (a *App) PopLastUserMessage() (string, []kit.LLMFilePart, error) {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return "", nil, fmt.Errorf("app is closed")
}
if a.busy {
a.mu.Unlock()
return "", nil, fmt.Errorf("cannot retry while the agent is working")
}
a.mu.Unlock()
ts := a.opts.TreeSession
if ts == nil {
return "", nil, fmt.Errorf("no tree session active; /retry requires a session")
}
// Walk the current branch backwards to find the most recent user message.
branch := ts.GetBranch("")
var target *session.MessageEntry
for i := len(branch) - 1; i >= 0; i-- {
me, ok := branch[i].(*session.MessageEntry)
if !ok {
continue
}
if me.Role == string(message.RoleUser) {
target = me
break
}
}
if target == nil {
return "", nil, fmt.Errorf("no user message to retry")
}
// Extract the prompt text and any image parts from the target entry.
msg, err := target.ToMessage()
if err != nil {
return "", nil, fmt.Errorf("decode user message: %w", err)
}
prompt := msg.Content()
var files []kit.LLMFilePart
for _, part := range msg.Parts {
if ic, ok := part.(message.ImageContent); ok {
files = append(files, kit.LLMFilePart{
Data: ic.Data,
MediaType: ic.MediaType,
})
}
}
// Move the leaf to the parent of the user message. The failed turn's
// entries (user message + any partial assistant/tool entries) are still
// in the tree file but no longer on the active branch, so they will not
// be re-sent to the LLM. runTurn() will append a fresh user message on
// the next call.
if err := ts.Branch(target.ParentID); err != nil {
return "", nil, fmt.Errorf("branch to parent: %w", err)
}
// Sync the in-memory store with the new branch position so subsequent
// reads (and ReloadMessagesFromTree() consumers) see the truncated view.
a.store.Clear()
a.store.Replace(ts.GetLLMMessages())
return prompt, files, nil
}
// AddContextMessage adds a user-role message to the conversation history
// without triggering an LLM response. Used by the ! shell command prefix
// to inject command output into context so the LLM can reference it in
@@ -356,6 +451,10 @@ func (a *App) AddContextMessage(text string) {
// tea.Program. customInstructions is optional text appended to the summary
// prompt (e.g. "Focus on the API design decisions").
//
// Any prompts queued via Run/RunWithFiles or steering messages injected via
// Steer/SteerWithFiles while compaction is running are flushed automatically
// once compaction completes (see releaseBusyAfterCompact).
//
// Satisfies ui.AppController.
func (a *App) CompactConversation(customInstructions string) error {
a.mu.Lock()
@@ -377,11 +476,7 @@ func (a *App) CompactConversation(customInstructions string) error {
go func() {
defer a.wg.Done()
defer func() {
a.mu.Lock()
a.busy = false
a.mu.Unlock()
}()
defer a.releaseBusyAfterCompact()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
@@ -420,6 +515,9 @@ func (a *App) CompactConversation(customInstructions string) error {
// CompactAsync is like CompactConversation but calls onComplete/onError
// callbacks instead of sending TUI events. Used by the extension API's
// ctx.Compact() which needs callback-based notification.
//
// Like CompactConversation, any prompts/steer messages received during
// compaction are flushed automatically once compaction finishes.
func (a *App) CompactAsync(customInstructions string, onComplete func(), onError func(string)) error {
a.mu.Lock()
if a.closed {
@@ -440,11 +538,7 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
go func() {
defer a.wg.Done()
defer func() {
a.mu.Lock()
a.busy = false
a.mu.Unlock()
}()
defer a.releaseBusyAfterCompact()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
@@ -489,6 +583,81 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
return nil
}
// releaseBusyAfterCompact is the deferred tail that runs at the end of every
// compaction goroutine (success, error, or panic-after-recover paths). It
// flips a.busy back to false, but before doing so it checks whether any
// prompts piled up while compaction was running:
//
// - Run/RunWithFiles append to a.queue when a.busy is set.
// - Steer/SteerWithFiles deposit messages into the SDK steer channel via
// Kit.InjectSteerWithFiles when a.busy is set.
//
// Without this hand-off the queue would sit idle until the user submits
// another prompt — see issue #27. If we find anything pending we keep busy
// set, splice the steer messages to the front of the queue, and start a
// fresh drainQueue goroutine to deliver them as a single batched turn.
func (a *App) releaseBusyAfterCompact() {
// Pull steer messages outside the app mutex; DrainSteer takes its own
// internal lock and we don't want to nest the two. The test seam
// (a.steerDrainFn) takes precedence so unit tests can inject fake
// steer items without a real *kit.Kit.
var steerItems []queueItem
switch {
case a.steerDrainFn != nil:
steerItems = a.steerDrainFn()
case a.opts.Kit != nil:
if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 {
steerItems = make([]queueItem, len(leftover))
for i, sm := range leftover {
steerItems[i] = queueItem{Prompt: sm.Text, Files: sm.Files}
}
}
}
a.mu.Lock()
// If the app was closed while compaction was running, drop everything
// and just clear busy. Run/Steer would have rejected new items already
// after Close(), but this guards against in-flight items that slipped
// in just before closed was set.
if a.closed {
a.queue = a.queue[:0]
a.busy = false
a.mu.Unlock()
return
}
// Combine steer-channel items (front) with the in-memory queue (back).
// Steer messages are placed first so they retain their "act now"
// semantics relative to ordinary queued prompts that arrived later.
pending := append(steerItems, a.queue...)
a.queue = a.queue[:0]
if len(pending) == 0 {
a.busy = false
a.mu.Unlock()
return
}
// Hand off to drainQueue: it will pick up the first item directly and
// scoop the rest from a.queue on its first iteration.
first := pending[0]
if len(pending) > 1 {
a.queue = append(a.queue, pending[1:]...)
}
// Stay busy across the goroutine swap.
a.wg.Add(1)
a.mu.Unlock()
// Notify the UI that steer-channel messages were consumed so the
// steering badge can clear; ordinary queued prompts will be reflected
// by the QueueUpdatedEvent that drainQueue emits as it picks them up.
if len(steerItems) > 0 {
a.sendEvent(SteerConsumedEvent{})
}
go a.drainQueue(first)
}
// --------------------------------------------------------------------------
// Non-interactive execution
// --------------------------------------------------------------------------
@@ -888,6 +1057,12 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
switch ev := e.(type) {
case kit.ToolCallEvent:
sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolCallStartEvent:
sendFn(ToolCallInputStartEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolKind: ev.ToolKind})
case kit.ToolCallDeltaEvent:
sendFn(ToolCallInputDeltaEvent{ToolCallID: ev.ToolCallID, Delta: ev.Delta})
case kit.ToolCallEndEvent:
sendFn(ToolCallInputEndEvent{ToolCallID: ev.ToolCallID})
case kit.ToolExecutionStartEvent:
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
case kit.ToolExecutionEndEvent:
@@ -917,7 +1092,7 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
case kit.StepUsageEvent:
a.recordStepUsage(ev, stepUsageSeen)
a.recordStepUsage(ev, stepUsageSeen, sendFn)
case kit.PasswordPromptEvent:
// Convert SDK PasswordPromptEvent to app PasswordPromptEvent
// The TUI will handle this and send the response back
@@ -932,6 +1107,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
Password: resp.Password,
Cancelled: resp.Cancelled,
}
case kit.TurnEndEvent:
a.handleTurnEnd(ev, sendFn)
}
}))
@@ -942,6 +1119,64 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
}
}
// handleTurnEnd inspects a turn's final StopReason and surfaces actionable
// feedback to the user when the turn ended in a state they can act on.
//
// Today the only surfaced case is FinishReasonLength — the model hit its
// configured max_output_tokens budget and the reply was truncated. Without
// this banner the TUI used to swallow the truncation silently, leading to
// "ghost" cut-offs with no indication of why.
//
// Separated from subscribeSDKEvents so tests can exercise it directly via a
// stubbed sendFn without standing up a full Kit.
func (a *App) handleTurnEnd(ev kit.TurnEndEvent, sendFn func(tea.Msg)) {
if sendFn == nil {
return
}
if ev.StopReason != kit.FinishReasonLength {
return
}
sendFn(ExtensionPrintEvent{
Level: "info",
Text: a.formatMaxTokensTruncatedMessage(),
})
}
// formatMaxTokensTruncatedMessage builds the user-facing explanation for a
// truncated turn. It reports the active max_output_tokens budget and, when
// known, the model's catalog output ceiling so the user can judge how much
// headroom is available.
func (a *App) formatMaxTokensTruncatedMessage() string {
k := a.opts.Kit
if k == nil {
// Extremely early / test-stub case: still emit a useful generic hint.
return "⚠ Response truncated: the model hit the configured max_output_tokens limit. " +
"Raise it with --max-tokens N, KIT_MAX_TOKENS=N, or per-model " +
"modelSettings[provider/model].maxTokens in config."
}
current := k.MaxTokens()
ceiling := k.MaxOutputLimit()
model := k.GetModelString()
msg := "⚠ Response truncated: "
if model != "" {
msg += fmt.Sprintf("%s hit the configured max_output_tokens limit", model)
} else {
msg += "the model hit the configured max_output_tokens limit"
}
if current > 0 {
msg += fmt.Sprintf(" (%d)", current)
}
msg += "."
if ceiling > 0 && current > 0 && ceiling > current {
msg += fmt.Sprintf(" This model supports up to %d output tokens.", ceiling)
}
msg += "\n\nRaise it with --max-tokens N, KIT_MAX_TOKENS=N, " +
"or per-model modelSettings[provider/model].maxTokens in your config. " +
"Re-run the last prompt after raising it to get the full response."
return msg
}
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
// non-interactive mode it cancels the root context, stopping any in-flight
@@ -1010,32 +1245,47 @@ func (a *App) NotifyModelChanged(provider, model string) {
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
//
// Coalescing: if a WidgetUpdateEvent is already queued and not yet consumed
// by the TUI event loop, additional calls within the same ~16 ms window are
// dropped. This prevents fast extension tickers from flooding BubbleTea's
// mailbox with redundant re-render triggers.
// Coalescing (leading + trailing edge): the first call in an idle period
// fires immediately for responsiveness. Subsequent calls within a ~16 ms
// debounce window are batched into a single trailing event delivered at
// the end of the window. The trailing send is essential for correctness:
// extensions routinely make tight SetWidget→RemoveWidget pairs (e.g. on
// SubagentEnd) and silently dropping the second call would leave the TUI's
// layout stuck on stale widget dimensions until some other event happens
// to trigger a re-render.
func (a *App) NotifyWidgetUpdate() {
// Coalesce: only one pending update at a time.
if !a.widgetUpdatePending.CompareAndSwap(false, true) {
// A leading-edge event is already in flight — mark that the runner
// state has changed again so the trailing send below picks it up.
a.widgetUpdateTrailing.Store(true)
return
}
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(WidgetUpdateEvent{})
// Reset the pending flag after a short debounce so subsequent calls
// within the same render cycle are also coalesced, but new updates
// after the cycle are allowed through.
go func() {
time.Sleep(16 * time.Millisecond) // ~1 frame at 60 fps
a.widgetUpdatePending.Store(false)
}()
} else {
if prog == nil {
// No program registered (non-interactive mode); clear the flag so
// future calls are never permanently blocked.
a.widgetUpdatePending.Store(false)
return
}
prog.Send(WidgetUpdateEvent{})
go func() {
time.Sleep(16 * time.Millisecond) // ~1 frame at 60 fps
// If any extra calls came in during the debounce window, deliver
// one trailing event so the TUI sees the latest widget state. We
// swap-and-test instead of plain-load so concurrent calls after
// the trailing send still race correctly with the pending reset.
if a.widgetUpdateTrailing.Swap(false) {
a.mu.Lock()
p := a.program
a.mu.Unlock()
if p != nil {
p.Send(WidgetUpdateEvent{})
}
}
a.widgetUpdatePending.Store(false)
}()
}
// NotifyContentReload sends a ContentReloadEvent to the TUI so it refreshes
@@ -1175,7 +1425,16 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
// recordStepUsage applies token/cost usage reported for a completed step.
// Step usage events arrive even when a turn is later cancelled, so this keeps
// the usage widget accurate on all stop paths.
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool) {
//
// Both session totals (cost, token counts) and the context window fill level
// are updated here so the status bar reflects progress after every LLM call,
// not just at the end of the full turn. Context fill monotonically increases
// across steps because each step re-sends the entire conversation plus any
// new tool results, so the numbers only go up.
//
// sendFn is called with a UsageUpdatedEvent to trigger a TUI re-render so
// the updated values are visible immediately.
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool, sendFn func(tea.Msg)) {
hasUsage := ev.InputTokens > 0 || ev.OutputTokens > 0 || ev.CacheReadTokens > 0 || ev.CacheWriteTokens > 0
if a.opts.Debug {
log.Printf("[DEBUG] recordStepUsage: hasUsage=%v input=%d output=%d cacheRead=%d cacheWrite=%d",
@@ -1196,11 +1455,21 @@ func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool)
int(ev.CacheReadTokens),
int(ev.CacheWriteTokens),
)
// NOTE: We do NOT call SetContextTokens here. Context fill is set once
// at turn completion via updateUsageFromTurnResult, which sums all token
// categories (Input + CacheRead + CacheCreate + Output) from FinalUsage.
// Per-step context tokens would cause the display to jump around during
// multi-step tool calls.
// Update context window fill from this step's usage. Each step sends
// the full conversation to the LLM, so the reported token counts
// represent the actual context utilization at that point.
contextFill := int(ev.InputTokens) + int(ev.CacheReadTokens) + int(ev.CacheWriteTokens) + int(ev.OutputTokens)
if contextFill > 0 {
if a.opts.Debug {
log.Printf("[DEBUG] recordStepUsage: SetContextTokens=%d (Input=%d + CacheRead=%d + CacheWrite=%d + Output=%d)",
contextFill, ev.InputTokens, ev.CacheReadTokens, ev.CacheWriteTokens, ev.OutputTokens)
}
a.opts.UsageTracker.SetContextTokens(contextFill)
}
// Notify the TUI so it re-renders the status bar with updated values.
if sendFn != nil {
sendFn(UsageUpdatedEvent{})
}
}
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
+456 -7
View File
@@ -3,11 +3,16 @@ package app
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/mark3labs/kit/internal/session"
)
// --------------------------------------------------------------------------
@@ -532,9 +537,9 @@ func TestQueueLength_reflects(t *testing.T) {
}
// TestRecordStepUsage_updatesTracker verifies that per-step usage updates are
// recorded immediately for cost tracking. Context tokens are NOT updated here
// (only via updateUsageFromTurnResult) to avoid display jumps during multi-step
// tool calls.
// recorded immediately for cost tracking. Context tokens are also updated so
// the status bar reflects context fill after every LLM call in a multi-step
// turn, not just at the end.
func TestRecordStepUsage_updatesTracker(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
@@ -545,7 +550,7 @@ func TestRecordStepUsage_updatesTracker(t *testing.T) {
OutputTokens: 45,
CacheReadTokens: 5,
CacheWriteTokens: 2,
}, nil)
}, nil, nil)
usage.mu.Lock()
defer usage.mu.Unlock()
@@ -557,9 +562,13 @@ func TestRecordStepUsage_updatesTracker(t *testing.T) {
t.Fatalf("unexpected usage update payload: in=%d out=%d cache_read=%d cache_write=%d",
usage.lastUpdateInput, usage.lastUpdateOutput, usage.lastUpdateCacheRead, usage.lastUpdateCacheWrite)
}
// Context tokens should NOT be updated by recordStepUsage (only by updateUsageFromTurnResult)
if usage.contextCalls != 0 {
t.Fatalf("expected 0 context token updates from recordStepUsage, got %d", usage.contextCalls)
// Context tokens should now be updated per-step (Input + CacheRead + CacheWrite + Output).
if usage.contextCalls != 1 {
t.Fatalf("expected 1 context token update from recordStepUsage, got %d", usage.contextCalls)
}
expectedContext := 120 + 45 + 5 + 2
if usage.lastContextTokens != expectedContext {
t.Fatalf("expected context tokens %d, got %d", expectedContext, usage.lastContextTokens)
}
}
@@ -666,3 +675,443 @@ func TestUpdateUsageFromTurnResult_contextTokensUsesAllCategories(t *testing.T)
expected, usage.contextCalls, usage.lastContextTokens)
}
}
// TestHandleTurnEnd_LengthEmitsWarning verifies that when the SDK reports a
// FinishReasonLength (max_output_tokens hit), the app surfaces a user-visible
// ExtensionPrintEvent with Level="info" so the TUI can render a banner
// instead of silently showing a truncated reply.
func TestHandleTurnEnd_LengthEmitsWarning(t *testing.T) {
app := New(Options{}, nil)
defer app.Close()
var mu sync.Mutex
var received []tea.Msg
sendFn := func(m tea.Msg) {
mu.Lock()
defer mu.Unlock()
received = append(received, m)
}
app.handleTurnEnd(kit.TurnEndEvent{StopReason: kit.FinishReasonLength}, sendFn)
mu.Lock()
defer mu.Unlock()
if len(received) != 1 {
t.Fatalf("expected 1 event on length stop, got %d", len(received))
}
ev, ok := received[0].(ExtensionPrintEvent)
if !ok {
t.Fatalf("expected ExtensionPrintEvent, got %T", received[0])
}
if ev.Level != "info" {
t.Errorf("expected Level=info, got %q", ev.Level)
}
if ev.Text == "" {
t.Error("expected non-empty warning text")
}
if !strings.Contains(ev.Text, "max_output_tokens") {
t.Errorf("warning text should mention max_output_tokens, got: %s", ev.Text)
}
}
// TestHandleTurnEnd_NonLengthIgnored verifies that ordinary stop reasons
// (stop, tool-calls, error, unknown, "") do not produce a warning banner.
func TestHandleTurnEnd_NonLengthIgnored(t *testing.T) {
app := New(Options{}, nil)
defer app.Close()
reasons := []string{
kit.FinishReasonStop,
kit.FinishReasonToolCalls,
kit.FinishReasonError,
kit.FinishReasonContentFilter,
kit.FinishReasonOther,
kit.FinishReasonUnknown,
"",
}
for _, r := range reasons {
var called bool
app.handleTurnEnd(kit.TurnEndEvent{StopReason: r}, func(m tea.Msg) {
called = true
})
if called {
t.Errorf("stop reason %q unexpectedly emitted a warning", r)
}
}
}
// TestHandleTurnEnd_NilSendFn guards against panics when no TUI listener is
// attached (e.g. early init or headless teardown).
func TestHandleTurnEnd_NilSendFn(t *testing.T) {
app := New(Options{}, nil)
defer app.Close()
// Should not panic with a nil sendFn.
app.handleTurnEnd(kit.TurnEndEvent{StopReason: kit.FinishReasonLength}, nil)
}
// TestFormatMaxTokensTruncatedMessage_NoKit verifies the fallback message
// when Options.Kit is nil (test/stub path).
func TestFormatMaxTokensTruncatedMessage_NoKit(t *testing.T) {
app := New(Options{}, nil)
defer app.Close()
msg := app.formatMaxTokensTruncatedMessage()
if msg == "" {
t.Fatal("expected non-empty fallback message")
}
for _, needle := range []string{"max_output_tokens", "--max-tokens", "KIT_MAX_TOKENS", "modelSettings"} {
if !strings.Contains(msg, needle) {
t.Errorf("fallback message missing %q:\n%s", needle, msg)
}
}
}
// --------------------------------------------------------------------------
// releaseBusyAfterCompact (issue #27)
// --------------------------------------------------------------------------
// TestReleaseBusyAfterCompact_flushesQueuedMessages is a regression test for
// issue #27: messages queued via Run() while /compact is running used to sit
// in a.queue indefinitely until the user typed another prompt. After the fix
// the deferred releaseBusyAfterCompact tail picks up any pending items and
// dispatches drainQueue automatically.
//
// We simulate the compaction completion path directly (bypassing the SDK)
// by toggling busy=true, populating the queue exactly as Run() would have
// during compaction, and then invoking releaseBusyAfterCompact.
func TestReleaseBusyAfterCompact_flushesQueuedMessages(t *testing.T) {
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
return turnResult("compacted then drained"), nil
},
)
app := newTestApp(stub)
defer app.Close()
// Simulate the state at the start of the compaction tail: busy is set
// and a couple of prompts have piled up in the queue while we were
// summarising. (Run() would have appended them and returned a queue
// length > 0 to the caller.)
app.mu.Lock()
app.busy = true
app.queue = append(app.queue,
queueItem{Prompt: "queued during compact #1"},
queueItem{Prompt: "queued during compact #2"},
)
app.mu.Unlock()
// Invoke the deferred tail directly. It should kick off drainQueue.
app.releaseBusyAfterCompact()
// drainQueue runs in a goroutine. Wait for the app to come back to idle.
ok := waitForCondition(2*time.Second, func() bool {
app.mu.Lock()
defer app.mu.Unlock()
return !app.busy
})
if !ok {
t.Fatal("app did not become idle after releaseBusyAfterCompact: queue not drained")
}
// Wait for any in-flight goroutine to finish before reading state.
app.wg.Wait()
if got := app.QueueLength(); got != 0 {
t.Fatalf("expected empty queue after drain, got %d", got)
}
if n := stub.callCount(); n == 0 {
t.Fatalf("expected stub PromptFunc to fire at least once after compact, got %d calls", n)
}
}
// TestReleaseBusyAfterCompact_idleWhenQueueEmpty verifies that with no
// pending messages the helper just clears busy and does NOT spawn a
// drainQueue goroutine (no spurious agent turn).
func TestReleaseBusyAfterCompact_idleWhenQueueEmpty(t *testing.T) {
stub := newStub()
app := newTestApp(stub)
defer app.Close()
app.mu.Lock()
app.busy = true
app.mu.Unlock()
app.releaseBusyAfterCompact()
app.mu.Lock()
busy := app.busy
app.mu.Unlock()
if busy {
t.Fatal("expected busy=false after releaseBusyAfterCompact with empty queue")
}
// Give any rogue goroutine a moment to (incorrectly) call PromptFunc.
time.Sleep(50 * time.Millisecond)
if n := stub.callCount(); n != 0 {
t.Fatalf("expected 0 PromptFunc calls when queue empty, got %d", n)
}
}
// TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue exercises the SDK
// steer-drain branch of releaseBusyAfterCompact (issue #27 follow-up).
//
// Production wires a.opts.Kit.DrainSteer() to pull messages that arrived via
// Steer/SteerWithFiles during compaction, but Options.Kit is *kit.Kit (a
// concrete struct) so unit tests cannot stand up a real instance without a
// full LLM backend. The test uses the unexported steerDrainFn seam to inject
// fake steer items, then asserts that:
//
// - Steer items are dispatched ahead of any prompts that piled up in
// a.queue (steer retains "act now" priority over ordinary queued
// prompts), and
// - the helper still hands off to drainQueue so the steer item actually
// fires (the previous behaviour left them stranded — see #27).
func TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue(t *testing.T) {
var pmu sync.Mutex
var firstPrompt string
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
return turnResult("steer dispatched"), nil
},
)
// Wrap PromptFunc so we can capture the prompt text the stub receives
// (newStubWithFuncs's fns ignore prompt; we need it to verify ordering).
capturingPrompt := func(ctx context.Context, prompt string) (*kit.TurnResult, error) {
pmu.Lock()
if firstPrompt == "" {
firstPrompt = prompt
}
pmu.Unlock()
return stub.fn(ctx, prompt)
}
app := New(Options{PromptFunc: capturingPrompt}, nil)
defer app.Close()
// Inject fake steer items via the test seam. In production the same
// items would have been delivered through Kit.InjectSteerWithFiles
// during /compact and pulled by DrainSteer here.
app.steerDrainFn = func() []queueItem {
return []queueItem{
{Prompt: "steer-1"},
{Prompt: "steer-2"},
}
}
// Simulate the state at the end of compaction: busy is set and a couple
// of regular Run() prompts have piled up after the steer messages.
app.mu.Lock()
app.busy = true
app.queue = append(app.queue,
queueItem{Prompt: "queued-1"},
queueItem{Prompt: "queued-2"},
)
app.mu.Unlock()
app.releaseBusyAfterCompact()
// Wait for the dispatched batch to complete.
ok := waitForCondition(2*time.Second, func() bool {
app.mu.Lock()
defer app.mu.Unlock()
return !app.busy
})
if !ok {
t.Fatal("app did not become idle after steer-spliced releaseBusyAfterCompact")
}
app.wg.Wait()
// drainQueue picks up `first` directly and batches the rest. With
// PromptFunc set, executeBatch invokes us with items[0] only — that
// item must be the first steer message, proving steer items were
// spliced ahead of the previously queued prompts.
pmu.Lock()
got := firstPrompt
pmu.Unlock()
if got != "steer-1" {
t.Fatalf("expected first dispatched prompt to be steer item %q (steer items must come before queued prompts), got %q",
"steer-1", got)
}
// Queue should be fully drained and PromptFunc must have actually fired.
if n := app.QueueLength(); n != 0 {
t.Fatalf("expected empty queue after drain, got %d entries", n)
}
if n := stub.callCount(); n == 0 {
t.Fatal("expected stub PromptFunc to fire at least once after splice")
}
}
// TestReleaseBusyAfterCompact_dropsQueueWhenClosed verifies that if the app
// was closed during compaction the helper discards any pending items rather
// than spawning drainQueue against a torn-down App.
func TestReleaseBusyAfterCompact_dropsQueueWhenClosed(t *testing.T) {
stub := newStub()
app := newTestApp(stub)
app.mu.Lock()
app.busy = true
app.queue = append(app.queue, queueItem{Prompt: "would have run"})
app.closed = true
app.mu.Unlock()
app.releaseBusyAfterCompact()
app.mu.Lock()
busy := app.busy
qLen := len(app.queue)
app.mu.Unlock()
if busy {
t.Fatal("expected busy=false even when closed")
}
if qLen != 0 {
t.Fatalf("expected queue cleared on closed app, got %d entries", qLen)
}
time.Sleep(20 * time.Millisecond)
if n := stub.callCount(); n != 0 {
t.Fatalf("expected 0 PromptFunc calls on closed app, got %d", n)
}
}
// --------------------------------------------------------------------------
// PopLastUserMessage (/retry building block)
// --------------------------------------------------------------------------
// TestPopLastUserMessage_NoTreeSession verifies that PopLastUserMessage
// returns an error when no tree session is active.
func TestPopLastUserMessage_NoTreeSession(t *testing.T) {
app := newTestApp(newStub())
defer app.Close()
prompt, files, err := app.PopLastUserMessage()
if err == nil {
t.Fatal("expected error when no tree session is active")
}
if prompt != "" || files != nil {
t.Fatalf("expected zero values on error, got prompt=%q files=%v", prompt, files)
}
}
// TestPopLastUserMessage_WhileBusy verifies that PopLastUserMessage
// refuses to truncate while the agent is busy (would race with executeBatch).
func TestPopLastUserMessage_WhileBusy(t *testing.T) {
app := newTestApp(newStub())
defer app.Close()
app.mu.Lock()
app.busy = true
app.mu.Unlock()
_, _, err := app.PopLastUserMessage()
if err == nil {
t.Fatal("expected error when agent is busy")
}
if !strings.Contains(err.Error(), "working") {
t.Fatalf("expected error mentioning busy/working, got %q", err.Error())
}
}
// TestPopLastUserMessage_WhenClosed verifies that PopLastUserMessage
// returns an error after Close().
func TestPopLastUserMessage_WhenClosed(t *testing.T) {
app := newTestApp(newStub())
app.Close()
_, _, err := app.PopLastUserMessage()
if err == nil {
t.Fatal("expected error on closed app")
}
}
// TestPopLastUserMessage_TruncatesAndReturnsPrompt verifies the happy path:
// a real tree session with user→assistant→user→assistant entries is
// truncated back to before the most recent user message, and that user's
// text is returned.
func TestPopLastUserMessage_TruncatesAndReturnsPrompt(t *testing.T) {
dir := t.TempDir()
ts, err := session.CreateTreeSession(dir)
if err != nil {
t.Fatalf("create tree session: %v", err)
}
defer func() { _ = ts.Close() }()
// Build history: user "first" → assistant "ack 1" → user "second" → assistant "ack 2".
if _, err := ts.AppendLLMMessage(fantasy.NewUserMessage("first")); err != nil {
t.Fatal(err)
}
if _, err := ts.AppendLLMMessage(fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "ack 1"}},
}); err != nil {
t.Fatal(err)
}
if _, err := ts.AppendLLMMessage(fantasy.NewUserMessage("second")); err != nil {
t.Fatal(err)
}
if _, err := ts.AppendLLMMessage(fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "ack 2"}},
}); err != nil {
t.Fatal(err)
}
app := New(Options{TreeSession: ts, PromptFunc: newStub().fn}, nil)
defer app.Close()
prompt, files, err := app.PopLastUserMessage()
if err != nil {
t.Fatalf("PopLastUserMessage: %v", err)
}
if prompt != "second" {
t.Fatalf("expected prompt=%q, got %q", "second", prompt)
}
if files != nil {
t.Fatalf("expected no files, got %v", files)
}
// After truncation the branch should only contain the first user
// message and its assistant response (the "second" turn is orphaned).
msgs := ts.GetLLMMessages()
if len(msgs) != 2 {
t.Fatalf("expected 2 messages on truncated branch, got %d", len(msgs))
}
if got := messageText(msgs[0]); got != "first" {
t.Fatalf("expected first message %q, got %q", "first", got)
}
if got := messageText(msgs[1]); got != "ack 1" {
t.Fatalf("expected second message %q, got %q", "ack 1", got)
}
}
// messageText extracts concatenated TextPart content from a fantasy.Message.
func messageText(m fantasy.Message) string {
var out strings.Builder
for _, p := range m.Content {
if tp, ok := p.(fantasy.TextPart); ok {
out.WriteString(tp.Text)
}
}
return out.String()
}
// TestPopLastUserMessage_NoUserOnBranch verifies that an empty tree (no
// user messages at all) returns a friendly error rather than panicking.
func TestPopLastUserMessage_NoUserOnBranch(t *testing.T) {
dir := t.TempDir()
ts, err := session.CreateTreeSession(dir)
if err != nil {
t.Fatalf("create tree session: %v", err)
}
defer func() { _ = ts.Close() }()
app := New(Options{TreeSession: ts, PromptFunc: newStub().fn}, nil)
defer app.Close()
_, _, err = app.PopLastUserMessage()
if err == nil {
t.Fatal("expected error when no user message exists on branch")
}
if !strings.Contains(err.Error(), "no user message") {
t.Fatalf("expected error mentioning missing user message, got %q", err.Error())
}
}
+36
View File
@@ -32,6 +32,36 @@ type ToolCallStartedEvent struct {
ToolArgs string
}
// ToolCallInputStartEvent is sent when the LLM begins generating tool call
// arguments. The tool name is known but the full argument JSON is still being
// streamed. UIs can use this to show a "running" indicator immediately instead
// of waiting for the full argument JSON to finish streaming.
type ToolCallInputStartEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
// ToolName is the name of the tool being called.
ToolName string
// ToolKind classifies the tool: "execute", "edit", "read", "search", "agent".
ToolKind string
}
// ToolCallInputDeltaEvent is sent for each streamed fragment of tool call
// arguments as they arrive from the LLM. Useful for live-previewing content
// or showing a progress indicator with byte count.
type ToolCallInputDeltaEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
// Delta is a JSON fragment of tool call arguments.
Delta string
}
// ToolCallInputEndEvent is sent when tool argument streaming is complete,
// before the tool call is parsed and execution begins.
type ToolCallInputEndEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
}
// ToolExecutionEvent is sent when a tool starts or finishes executing.
// The IsStarting flag distinguishes between the start and end of execution.
type ToolExecutionEvent struct {
@@ -180,6 +210,12 @@ type ModelChangedEvent struct {
ModelName string
}
// UsageUpdatedEvent is sent after each completed LLM step to notify the TUI
// that token counts and costs have changed. The UsageTracker is updated
// in-place before this event is sent; the TUI just needs to re-render to
// reflect the new values in the status bar.
type UsageUpdatedEvent struct{}
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
// from its WidgetProvider on the next render cycle.
-5
View File
@@ -13,11 +13,6 @@ type MessageStore struct {
messages []kit.LLMMessage
}
// NewMessageStore creates an empty MessageStore.
func NewMessageStore() *MessageStore {
return &MessageStore{}
}
// NewMessageStoreWithMessages creates a MessageStore pre-populated with the
// given messages. This is used when loading an existing session at startup.
func NewMessageStoreWithMessages(msgs []kit.LLMMessage) *MessageStore {
+10 -10
View File
@@ -29,7 +29,7 @@ func textOf(msg kit.LLMMessage) string {
// --------------------------------------------------------------------------
func TestNewMessageStore_empty(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
if s == nil {
t.Fatal("expected non-nil store")
}
@@ -72,7 +72,7 @@ func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
// --------------------------------------------------------------------------
func TestAdd_appendsMessage(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
s.Add(makeTextMsg("user", "first"))
s.Add(makeTextMsg("assistant", "second"))
@@ -82,7 +82,7 @@ func TestAdd_appendsMessage(t *testing.T) {
}
func TestAdd_preservesOrder(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
texts := []string{"a", "b", "c"}
for _, t2 := range texts {
s.Add(makeTextMsg("user", t2))
@@ -100,7 +100,7 @@ func TestAdd_preservesOrder(t *testing.T) {
// --------------------------------------------------------------------------
func TestReplace_swapsHistory(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
s.Add(makeTextMsg("user", "old"))
replacement := []kit.LLMMessage{
@@ -120,7 +120,7 @@ func TestReplace_swapsHistory(t *testing.T) {
// Replace must deep-copy the incoming slice.
func TestReplace_isolatesInput(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
replacement := []kit.LLMMessage{makeTextMsg("user", "original")}
s.Replace(replacement)
@@ -137,7 +137,7 @@ func TestReplace_isolatesInput(t *testing.T) {
// --------------------------------------------------------------------------
func TestGetAll_returnsCopy(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
s.Add(makeTextMsg("user", "hello"))
got := s.GetAll()
@@ -151,7 +151,7 @@ func TestGetAll_returnsCopy(t *testing.T) {
}
func TestGetAll_emptyStore(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
got := s.GetAll()
if len(got) != 0 {
t.Fatalf("expected empty slice, got %d elements", len(got))
@@ -163,7 +163,7 @@ func TestGetAll_emptyStore(t *testing.T) {
// --------------------------------------------------------------------------
func TestClear_removesAllMessages(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
s.Add(makeTextMsg("user", "a"))
s.Add(makeTextMsg("user", "b"))
s.Clear()
@@ -174,7 +174,7 @@ func TestClear_removesAllMessages(t *testing.T) {
}
func TestClear_allowsSubsequentAdds(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
s.Add(makeTextMsg("user", "before"))
s.Clear()
s.Add(makeTextMsg("user", "after"))
@@ -193,7 +193,7 @@ func TestClear_allowsSubsequentAdds(t *testing.T) {
// --------------------------------------------------------------------------
func TestConcurrentAccess(t *testing.T) {
s := NewMessageStore()
s := NewMessageStoreWithMessages(nil)
done := make(chan struct{})
// Writer goroutine.
+143 -45
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -9,11 +10,11 @@ import (
"time"
)
// CredentialStore holds all stored credentials for various providers.
// Currently supports Anthropic and OpenAI credentials with both OAuth and API key authentication methods.
// CredentialStore holds stored credentials for Anthropic, OpenAI, and GitHub Copilot.
type CredentialStore struct {
Anthropic *AnthropicCredentials `json:"anthropic,omitempty"`
OpenAI *OpenAICredentials `json:"openai,omitempty"`
Copilot *CopilotCredentials `json:"copilot,omitempty"`
}
// AnthropicCredentials holds Anthropic API credentials supporting both OAuth
@@ -43,6 +44,16 @@ type OpenAICredentials struct {
CreatedAt time.Time `json:"created_at"`
}
// CopilotCredentials holds GitHub OAuth credentials and the short-lived
// GitHub Copilot API token derived from them.
type CopilotCredentials struct {
Type string `json:"type"` // "oauth"
GitHubToken string `json:"github_token,omitempty"` // GitHub device-flow OAuth token
CopilotAccessToken string `json:"copilot_access_token,omitempty"` // Short-lived Copilot API token
ExpiresAt int64 `json:"expires_at,omitempty"` // Copilot token expiry
CreatedAt time.Time `json:"created_at"`
}
// oauthTokenExpired reports whether an OAuth token with the given type and
// expiry unix timestamp is past its expiry. Returns false for API key
// credentials or when no expiry is set.
@@ -91,6 +102,16 @@ func (c *OpenAICredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// IsExpired checks if the Copilot API token is expired.
func (c *CopilotCredentials) IsExpired() bool {
return oauthTokenExpired(c.Type, c.ExpiresAt)
}
// NeedsRefresh reports whether the Copilot API token should be renewed.
func (c *CopilotCredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// CredentialManager handles secure storage and retrieval of authentication credentials.
// It manages a JSON file stored in the user's config directory with appropriate
// file permissions for security.
@@ -222,7 +243,7 @@ func (cm *CredentialManager) RemoveAnthropicCredentials() error {
store.Anthropic = nil
// If store is empty, remove the file entirely
if store.Anthropic == nil {
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove credentials file: %w", err)
}
@@ -255,29 +276,6 @@ func (cm *CredentialManager) HasAnthropicCredentials() (bool, error) {
}
}
// SetOpenAICredentials stores OpenAI API key credentials. It validates the
// API key format before storing. The API key must start with "sk-" and be
// at least 20 characters long. Returns an error if the API key is invalid or
// if storage fails.
func (cm *CredentialManager) SetOpenAICredentials(apiKey string) error {
if err := validateOpenAIAPIKey(apiKey); err != nil {
return err
}
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.OpenAI = &OpenAICredentials{
Type: "api_key",
APIKey: apiKey,
CreatedAt: time.Now(),
}
return cm.SaveCredentials(store)
}
// GetOpenAICredentials retrieves stored OpenAI credentials. Returns nil if
// no credentials are stored. The returned credentials may be either OAuth or API
// key type, check the Type field to determine which.
@@ -302,7 +300,7 @@ func (cm *CredentialManager) RemoveOpenAICredentials() error {
store.OpenAI = nil
// If store is empty, remove the file entirely
if store.Anthropic == nil && store.OpenAI == nil {
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove credentials file: %w", err)
}
@@ -312,6 +310,104 @@ func (cm *CredentialManager) RemoveOpenAICredentials() error {
return cm.SaveCredentials(store)
}
// GetCopilotCredentials retrieves stored GitHub Copilot credentials.
func (cm *CredentialManager) GetCopilotCredentials() (*CopilotCredentials, error) {
store, err := cm.LoadCredentials()
if err != nil {
return nil, err
}
return store.Copilot, nil
}
// RemoveCopilotCredentials removes stored GitHub Copilot credentials.
func (cm *CredentialManager) RemoveCopilotCredentials() error {
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.Copilot = nil
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove credentials file: %w", err)
}
return nil
}
return cm.SaveCredentials(store)
}
// HasCopilotCredentials checks if valid GitHub Copilot credentials are stored.
func (cm *CredentialManager) HasCopilotCredentials() (bool, error) {
creds, err := cm.GetCopilotCredentials()
if err != nil {
return false, err
}
if creds == nil {
return false, nil
}
return creds.Type == "oauth" && creds.GitHubToken != "", nil
}
// SetCopilotOAuthCredentials stores GitHub Copilot OAuth credentials.
func (cm *CredentialManager) SetCopilotOAuthCredentials(creds *CopilotCredentials) error {
store, err := cm.LoadCredentials()
if err != nil {
return err
}
store.Copilot = creds
return cm.SaveCredentials(store)
}
// GetValidCopilotAccessToken returns a fresh Copilot API token, renewing it
// with the stored GitHub OAuth token when needed.
func (cm *CredentialManager) GetValidCopilotAccessToken() (string, error) {
return cm.GetValidCopilotAccessTokenContext(context.Background())
}
// GetValidCopilotAccessTokenContext returns a fresh Copilot API token, renewing
// it with the stored GitHub OAuth token when needed.
func (cm *CredentialManager) GetValidCopilotAccessTokenContext(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
}
creds, err := cm.GetCopilotCredentials()
if err != nil {
return "", err
}
if creds == nil {
return "", fmt.Errorf("no Copilot credentials found")
}
if creds.Type != "oauth" {
return "", fmt.Errorf("unknown credential type: %s", creds.Type)
}
if creds.GitHubToken == "" {
return "", fmt.Errorf("GitHub OAuth token missing from Copilot credentials")
}
if creds.CopilotAccessToken == "" || creds.NeedsRefresh() {
client := NewCopilotOAuthClient()
newCreds, err := client.RefreshCopilotToken(ctx, creds.GitHubToken)
if err != nil {
return "", fmt.Errorf("failed to refresh Copilot token: %w", err)
}
newCreds.CreatedAt = creds.CreatedAt
if err := cm.SetCopilotOAuthCredentials(newCreds); err != nil {
return "", fmt.Errorf("failed to save refreshed Copilot token: %w", err)
}
return newCreds.CopilotAccessToken, nil
}
return creds.CopilotAccessToken, nil
}
// HasOpenAICredentials checks if valid OpenAI credentials are stored.
// Returns true if either a non-empty OAuth access token or API key is present,
// false otherwise. Returns an error if credentials cannot be loaded.
@@ -417,24 +513,18 @@ func validateAnthropicAPIKey(apiKey string) error {
return nil
}
// validateOpenAIAPIKey validates the format of an OpenAI API key
func validateOpenAIAPIKey(apiKey string) error {
apiKey = strings.TrimSpace(apiKey)
// CredentialSourceOAuth is the source description returned by
// GetAnthropicAPIKey when the key resolves to stored OAuth credentials.
// Consumers should compare against this constant (or use IsAnthropicOAuth)
// rather than matching the string literal.
const CredentialSourceOAuth = "stored OAuth credentials"
if apiKey == "" {
return fmt.Errorf("API key cannot be empty")
}
// OpenAI API keys typically start with "sk-" and are quite long
if !strings.HasPrefix(apiKey, "sk-") {
return fmt.Errorf("invalid OpenAI API key format (should start with 'sk-')")
}
if len(apiKey) < 20 {
return fmt.Errorf("API key appears to be too short")
}
return nil
// IsAnthropicOAuth reports whether the active Anthropic credential resolves
// to a stored OAuth token (in which case the user is not billed per-token).
// flagValue is the --provider-api-key flag value (may be empty).
func IsAnthropicOAuth(flagValue string) bool {
_, source, err := GetAnthropicAPIKey(flagValue)
return err == nil && source == CredentialSourceOAuth
}
// GetAnthropicAPIKey retrieves an Anthropic API key from multiple sources in priority order:
@@ -459,7 +549,7 @@ func GetAnthropicAPIKey(flagValue string) (string, string, error) {
if err != nil {
return "", "", fmt.Errorf("failed to get valid OAuth token: %w", err)
}
return token, "stored OAuth credentials", nil
return token, CredentialSourceOAuth, nil
} else if creds.Type == "api_key" && creds.APIKey != "" {
return creds.APIKey, "stored API key", nil
}
@@ -471,5 +561,13 @@ func GetAnthropicAPIKey(flagValue string) (string, string, error) {
return envKey, "ANTHROPIC_API_KEY environment variable", nil
}
// Check if OpenAI credentials exist to provide a helpful suggestion
if cm != nil {
hasOpenAI, _ := cm.HasOpenAICredentials()
if hasOpenAI {
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag\n\nNote: OpenAI credentials were detected. To use OpenAI, run with --model openai/gpt-5.4 or set it as default:\n kit auth login openai --set-default")
}
}
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag")
}
+97
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCredentialManager(t *testing.T) {
@@ -215,6 +216,7 @@ func TestCredentialStorePersistence(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
credentialsPath := filepath.Join(tempDir, "credentials.json")
@@ -252,3 +254,98 @@ func TestCredentialStorePersistence(t *testing.T) {
t.Errorf("Expected file permissions 0600, got %v", info.Mode().Perm())
}
}
func TestCopilotCredentials(t *testing.T) {
tempDir, err := os.MkdirTemp("", "kit-auth-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
cm := &CredentialManager{
credentialsPath: filepath.Join(tempDir, "credentials.json"),
}
creds := &CopilotCredentials{
Type: "oauth",
GitHubToken: "github-token",
CopilotAccessToken: "copilot-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
CreatedAt: time.Now(),
}
if err := cm.SetCopilotOAuthCredentials(creds); err != nil {
t.Fatalf("SetCopilotOAuthCredentials failed: %v", err)
}
hasAuth, err := cm.HasCopilotCredentials()
if err != nil {
t.Fatalf("HasCopilotCredentials failed: %v", err)
}
if !hasAuth {
t.Fatal("Expected Copilot credentials")
}
token, err := cm.GetValidCopilotAccessToken()
if err != nil {
t.Fatalf("GetValidCopilotAccessToken failed: %v", err)
}
if token != creds.CopilotAccessToken {
t.Fatalf("Expected Copilot token %q, got %q", creds.CopilotAccessToken, token)
}
if err := cm.RemoveCopilotCredentials(); err != nil {
t.Fatalf("RemoveCopilotCredentials failed: %v", err)
}
hasAuth, err = cm.HasCopilotCredentials()
if err != nil {
t.Fatalf("HasCopilotCredentials after removal failed: %v", err)
}
if hasAuth {
t.Fatal("Expected no Copilot credentials after removal")
}
}
func TestRemoveCredentialsPreservesOtherProviders(t *testing.T) {
tempDir, err := os.MkdirTemp("", "kit-auth-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
cm := &CredentialManager{
credentialsPath: filepath.Join(tempDir, "credentials.json"),
}
if err := cm.SetOpenAIOAuthCredentials(&OpenAICredentials{
Type: "oauth",
AccessToken: "openai-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
AccountID: "account",
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("SetOpenAIOAuthCredentials failed: %v", err)
}
if err := cm.SetCopilotOAuthCredentials(&CopilotCredentials{
Type: "oauth",
GitHubToken: "github-token",
CopilotAccessToken: "copilot-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("SetCopilotOAuthCredentials failed: %v", err)
}
if err := cm.RemoveCopilotCredentials(); err != nil {
t.Fatalf("RemoveCopilotCredentials failed: %v", err)
}
hasOpenAI, err := cm.HasOpenAICredentials()
if err != nil {
t.Fatalf("HasOpenAICredentials failed: %v", err)
}
if !hasOpenAI {
t.Fatal("Expected OpenAI credentials to remain after removing Copilot credentials")
}
}
+257
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -211,6 +212,262 @@ type OpenAIOAuthClient struct {
Scopes string
}
// CopilotOAuthClient handles GitHub device-flow OAuth and exchanges the
// GitHub token for a short-lived GitHub Copilot API token.
//
// The GitHub token comes from GitHub's OAuth device flow. It is then presented
// to GitHub's internal Copilot token endpoint, which returns the bearer token
// used by api.githubcopilot.com.
type CopilotOAuthClient struct {
ClientID string
DeviceURL string
TokenURL string
CopilotURL string
Scopes string
PollTimeout time.Duration
ClientTimeout time.Duration
}
// CopilotDeviceCode contains data returned by GitHub's device-code endpoint.
type CopilotDeviceCode struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// NewCopilotOAuthClient creates a GitHub Copilot OAuth client.
func NewCopilotOAuthClient() *CopilotOAuthClient {
return &CopilotOAuthClient{
ClientID: "Iv1.b507a08c87ecfe98",
DeviceURL: "https://github.com/login/device/code",
TokenURL: "https://github.com/login/oauth/access_token",
CopilotURL: "https://api.github.com/copilot_internal/v2/token",
Scopes: "read:user",
PollTimeout: 15 * time.Minute,
ClientTimeout: 30 * time.Second,
}
}
// StartDeviceFlow requests a GitHub device code for browser login.
//
// The returned user code and verification URI are displayed by loginCopilot.
// GitHub's response may omit interval, so this method normalizes it to the
// documented five-second default.
func (c *CopilotOAuthClient) StartDeviceFlow(ctx context.Context) (*CopilotDeviceCode, error) {
if ctx == nil {
ctx = context.Background()
}
data := url.Values{
"client_id": {c.ClientID},
"scope": {c.Scopes},
}
req, err := http.NewRequestWithContext(ctx, "POST", c.DeviceURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create device-code request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request device code: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("device-code request failed with status %d: %s", resp.StatusCode, string(body))
}
var code CopilotDeviceCode
if err := json.NewDecoder(resp.Body).Decode(&code); err != nil {
return nil, fmt.Errorf("failed to decode device-code response: %w", err)
}
if code.DeviceCode == "" || code.UserCode == "" || code.VerificationURI == "" {
return nil, fmt.Errorf("device-code response missing required fields")
}
if code.Interval <= 0 {
code.Interval = 5
}
return &code, nil
}
// PollDeviceToken waits until the user authorizes the device code and returns
// the resulting GitHub OAuth token.
//
// It follows GitHub's device-flow polling contract: authorization_pending keeps
// polling, slow_down increases the interval, and polling stops at the earlier of
// the client timeout or the device-code expiry.
func (c *CopilotOAuthClient) PollDeviceToken(ctx context.Context, deviceCode *CopilotDeviceCode) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if deviceCode == nil || deviceCode.DeviceCode == "" {
return "", fmt.Errorf("device code missing")
}
deadline := time.Now().Add(c.PollTimeout)
if deviceCode.ExpiresIn > 0 {
expiresAt := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
if expiresAt.Before(deadline) {
deadline = expiresAt
}
}
interval := time.Duration(deviceCode.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
for time.Now().Before(deadline) {
wait := interval
if remaining := time.Until(deadline); remaining < wait {
wait = remaining
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
}
data := url.Values{
"client_id": {c.ClientID},
"device_code": {deviceCode.DeviceCode},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
}
req, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create device-token request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
if err != nil {
return "", fmt.Errorf("failed to poll device token: %w", err)
}
var tokenResp struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
Description string `json:"error_description"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&tokenResp)
_ = resp.Body.Close()
if decodeErr != nil {
return "", fmt.Errorf("failed to decode device-token response: %w", decodeErr)
}
if tokenResp.AccessToken != "" {
return tokenResp.AccessToken, nil
}
switch tokenResp.Error {
case "authorization_pending":
continue
case "slow_down":
interval += 5 * time.Second
continue
case "expired_token":
return "", fmt.Errorf("device code expired; restart login")
case "access_denied":
return "", fmt.Errorf("github login denied")
case "":
return "", fmt.Errorf("device-token request failed with status %d", resp.StatusCode)
default:
if tokenResp.Description != "" {
return "", fmt.Errorf("device-token request failed: %s: %s", tokenResp.Error, tokenResp.Description)
}
return "", fmt.Errorf("device-token request failed: %s", tokenResp.Error)
}
}
return "", fmt.Errorf("timed out waiting for github device authorization")
}
// ExchangeGitHubToken converts a GitHub OAuth token into a Copilot API token.
// It is a semantic wrapper over RefreshCopilotToken used by the login flow.
func (c *CopilotOAuthClient) ExchangeGitHubToken(ctx context.Context, githubToken string) (*CopilotCredentials, error) {
return c.RefreshCopilotToken(ctx, githubToken)
}
// RefreshCopilotToken obtains a fresh short-lived Copilot token from GitHub.
//
// GitHub may return expires_at as either a Unix timestamp or RFC3339 string.
// parseCopilotExpiry handles both forms and falls back to a conservative
// 20-minute lifetime when the field is absent or unrecognized.
func (c *CopilotOAuthClient) RefreshCopilotToken(ctx context.Context, githubToken string) (*CopilotCredentials, error) {
if ctx == nil {
ctx = context.Background()
}
req, err := http.NewRequestWithContext(ctx, "GET", c.CopilotURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create copilot token request: %w", err)
}
req.Header.Set("Authorization", "token "+githubToken)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "kit")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := (&http.Client{Timeout: c.ClientTimeout}).Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request copilot token: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("copilot token request failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
Token string `json:"token"`
ExpiresAt any `json:"expires_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode copilot token response: %w", err)
}
if tokenResp.Token == "" {
return nil, fmt.Errorf("copilot token response missing token")
}
expiresAt := parseCopilotExpiry(tokenResp.ExpiresAt)
if expiresAt == 0 {
expiresAt = time.Now().Add(20 * time.Minute).Unix()
}
return &CopilotCredentials{
Type: "oauth",
GitHubToken: githubToken,
CopilotAccessToken: tokenResp.Token,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
}, nil
}
// parseCopilotExpiry normalizes GitHub's expires_at variants to a Unix second.
func parseCopilotExpiry(value any) int64 {
switch v := value.(type) {
case float64:
return int64(v)
case string:
if parsed, err := strconv.ParseInt(v, 10, 64); err == nil {
return parsed
}
if parsed, err := time.Parse(time.RFC3339, v); err == nil {
return parsed.Unix()
}
}
return 0
}
// NewOpenAIOAuthClient creates a new OAuth client configured for OpenAI Codex OAuth.
// This uses the public client ID for CLI applications with PKCE for security.
func NewOpenAIOAuthClient() *OpenAIOAuthClient {
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCopilotStartDeviceFlow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm failed: %v", err)
}
if r.Form.Get("client_id") != "client-id" {
t.Fatalf("expected client id, got %q", r.Form.Get("client_id"))
}
if r.Form.Get("scope") != "read:user" {
t.Fatalf("expected scope, got %q", r.Form.Get("scope"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"device_code": "device-code",
"user_code": "USER-CODE",
"verification_uri": "https://github.com/login/device",
"expires_in": 600,
"interval": 1,
})
}))
defer server.Close()
client := NewCopilotOAuthClient()
client.ClientID = "client-id"
client.DeviceURL = server.URL
code, err := client.StartDeviceFlow(context.Background())
if err != nil {
t.Fatalf("StartDeviceFlow failed: %v", err)
}
if code.DeviceCode != "device-code" || code.UserCode != "USER-CODE" || code.Interval != 1 {
t.Fatalf("unexpected device code: %#v", code)
}
}
func TestCopilotPollDeviceToken(t *testing.T) {
polls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
polls++
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm failed: %v", err)
}
if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" {
t.Fatalf("unexpected grant type: %q", r.Form.Get("grant_type"))
}
if polls == 1 {
_ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "github-token"})
}))
defer server.Close()
client := NewCopilotOAuthClient()
client.ClientID = "client-id"
client.TokenURL = server.URL
client.PollTimeout = 5 * time.Second
client.ClientTimeout = time.Second
token, err := client.PollDeviceToken(context.Background(), &CopilotDeviceCode{
DeviceCode: "device-code",
ExpiresIn: 10,
Interval: 1,
})
if err != nil {
t.Fatalf("PollDeviceToken failed: %v", err)
}
if token != "github-token" {
t.Fatalf("expected github-token, got %q", token)
}
if polls != 2 {
t.Fatalf("expected 2 polls, got %d", polls)
}
}
func TestCopilotRefreshToken(t *testing.T) {
expiresAt := time.Now().Add(time.Hour).Unix()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.Header.Get("Authorization") != "token github-token" {
t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization"))
}
if r.Header.Get("User-Agent") != "kit" {
t.Fatalf("unexpected user agent: %q", r.Header.Get("User-Agent"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"token": "copilot-token",
"expires_at": expiresAt,
})
}))
defer server.Close()
client := NewCopilotOAuthClient()
client.CopilotURL = server.URL
creds, err := client.RefreshCopilotToken(context.Background(), "github-token")
if err != nil {
t.Fatalf("RefreshCopilotToken failed: %v", err)
}
if creds.GitHubToken != "github-token" || creds.CopilotAccessToken != "copilot-token" {
t.Fatalf("unexpected credentials: %#v", creds)
}
if creds.ExpiresAt != expiresAt {
t.Fatalf("expected expires_at %d, got %d", expiresAt, creds.ExpiresAt)
}
}
+48
View File
@@ -30,6 +30,31 @@ type MCPServerConfig struct {
OAuthClientSecret string `json:"oauthClientSecret,omitempty" yaml:"oauthClientSecret,omitempty"`
OAuthScopes []string `json:"oauthScopes,omitempty" yaml:"oauthScopes,omitempty"`
// NoOAuth disables OAuth transport configuration for this server, even
// when the connection pool has an auth handler. Use this for public MCP
// servers (e.g. PubMed) that don't require authentication. Without this
// flag, the pool would attach OAuth transport to every remote server,
// causing proactive dynamic-client-registration attempts that fail on
// servers that don't support it.
NoOAuth bool `json:"noOAuth,omitempty" yaml:"noOAuth,omitempty"`
// TasksMode controls when this server's tools/call requests are augmented
// with MCP task metadata (turning a synchronous call into an asynchronous,
// pollable job — see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks).
//
// Valid values:
// - "" or "auto": (default) augment requests with task metadata only
// when the server advertises tasks/toolCalls capability during initialize.
// - "never": never augment — every tool call is synchronous, regardless
// of server capability.
// - "always": always augment, even when the server didn't advertise
// task support. The server may still respond synchronously; this just
// opts in unconditionally on the client side.
//
// In all modes, when the server returns a CreateTaskResult the client polls
// tasks/get / tasks/result until the task reaches a terminal state.
TasksMode string `json:"tasksMode,omitempty" yaml:"tasksMode,omitempty"`
// InProcessServer holds a live *server.MCPServer for in-process transport.
// When set (and Type is "inprocess"), the connection pool creates an
// in-process client instead of spawning a subprocess or making HTTP calls.
@@ -59,6 +84,8 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
OAuthClientID string `json:"oauthClientId,omitempty" yaml:"oauthClientId,omitempty"`
OAuthClientSecret string `json:"oauthClientSecret,omitempty" yaml:"oauthClientSecret,omitempty"`
OAuthScopes []string `json:"oauthScopes,omitempty" yaml:"oauthScopes,omitempty"`
NoOAuth bool `json:"noOAuth,omitempty" yaml:"noOAuth,omitempty"`
TasksMode string `json:"tasksMode,omitempty" yaml:"tasksMode,omitempty"`
}
// Also try legacy format
@@ -71,6 +98,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
Headers []string `json:"headers,omitempty"`
AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"`
ExcludedTools []string `json:"excludedTools,omitempty" yaml:"excludedTools,omitempty"`
TasksMode string `json:"tasksMode,omitempty" yaml:"tasksMode,omitempty"`
}
// Try new format first
@@ -86,6 +114,8 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
s.OAuthClientID = newConfig.OAuthClientID
s.OAuthClientSecret = newConfig.OAuthClientSecret
s.OAuthScopes = newConfig.OAuthScopes
s.NoOAuth = newConfig.NoOAuth
s.TasksMode = newConfig.TasksMode
return nil
}
@@ -106,6 +136,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
s.Headers = legacyConfig.Headers
s.AllowedTools = legacyConfig.AllowedTools
s.ExcludedTools = legacyConfig.ExcludedTools
s.TasksMode = legacyConfig.TasksMode
// Infer type from legacy format for better compatibility
// Only set Type when it doesn't change existing transport behavior
@@ -314,6 +345,17 @@ func (c *Config) Validate() error {
return fmt.Errorf("server %s: allowedTools and excludedTools are mutually exclusive", serverName)
}
// Reject unknown tasksMode values up front so a typo (e.g. "alwasy")
// fails loud here instead of being silently downgraded to "auto" by
// the runtime parser. Comparison is case-insensitive to match
// tools.ParseTaskMode.
switch strings.ToLower(strings.TrimSpace(serverConfig.TasksMode)) {
case "", "auto", "never", "always":
// ok
default:
return fmt.Errorf("server %s: invalid tasksMode %q (expected one of: auto, never, always)", serverName, serverConfig.TasksMode)
}
transport := serverConfig.GetTransportType()
switch transport {
case "stdio":
@@ -451,6 +493,12 @@ mcpServers:
# maxTokens: 16384
# systemPrompt: "You are a deep reasoning assistant." # or a file path
# Skills configuration (all optional)
# no-skills: false # Set to true to disable all skill loading
# skill: # Explicit skill files/dirs (disables auto-discovery)
# - "/path/to/skill.md"
# skills-dir: "/path/to/skills" # Override project-local directory for auto-discovery
# API Configuration (can also use environment variables)
# provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google
# provider-url: "https://api.openai.com/v1" # Base URL for OpenAI, Anthropic, or Ollama
+92
View File
@@ -205,6 +205,9 @@ func TestEnsureConfigExists(t *testing.T) {
"type: \"local\"",
"type: \"remote\"",
"Core tools",
"# Skills configuration",
"no-skills:",
"skills-dir:",
}
for _, expected := range expectedSections {
@@ -627,3 +630,92 @@ func TestMCPServerConfig_OAuthFields_Omitted(t *testing.T) {
t.Errorf("Expected empty OAuthScopes, got %v", cfg.OAuthScopes)
}
}
func TestMCPServerConfig_TasksMode_NewFormat(t *testing.T) {
jsonData := `{
"type": "remote",
"url": "https://my-mcp-server.com",
"tasksMode": "always"
}`
var cfg MCPServerConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.TasksMode != "always" {
t.Errorf("expected TasksMode 'always', got %q", cfg.TasksMode)
}
}
func TestMCPServerConfig_TasksMode_LegacyFormat(t *testing.T) {
// tasksMode also recognised in the legacy unmarshal path so users on
// the older command/args shape can opt in without migrating.
jsonData := `{
"command": "npx",
"args": ["@modelcontextprotocol/server-filesystem", "/path"],
"tasksMode": "never"
}`
var cfg MCPServerConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.TasksMode != "never" {
t.Errorf("expected TasksMode 'never', got %q", cfg.TasksMode)
}
}
func TestMCPServerConfig_TasksMode_DefaultEmpty(t *testing.T) {
// When tasksMode is not set the field stays empty, which downstream
// resolves to "auto" via tools.ParseTaskMode.
jsonData := `{"type":"remote","url":"https://x.example"}`
var cfg MCPServerConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if cfg.TasksMode != "" {
t.Errorf("expected default TasksMode to be empty, got %q", cfg.TasksMode)
}
}
func TestConfig_Validate_TasksMode(t *testing.T) {
t.Run("empty is valid", func(t *testing.T) {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"a": {Type: "remote", URL: "https://x.example"},
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("empty TasksMode should validate, got %v", err)
}
})
t.Run("known values are valid", func(t *testing.T) {
for _, mode := range []string{"auto", "never", "always", "AUTO", " always "} {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"a": {Type: "remote", URL: "https://x.example", TasksMode: mode},
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("TasksMode=%q should validate, got %v", mode, err)
}
}
})
t.Run("typo is rejected with a clear error", func(t *testing.T) {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"buildbot": {Type: "remote", URL: "https://x.example", TasksMode: "alwasy"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("expected validation error for invalid TasksMode")
}
// Error must mention the server name AND the bad value so the
// user knows where to look.
msg := err.Error()
if !strings.Contains(msg, "buildbot") || !strings.Contains(msg, `"alwasy"`) {
t.Errorf("error %q should mention both server name and bad value", msg)
}
})
}
+25 -9
View File
@@ -7,32 +7,48 @@ import (
"github.com/spf13/viper"
)
// LoadAndValidateConfig loads configuration from viper, fixes environment variable
// casing issues, and validates the configuration. Returns an error if loading or
// validation fails.
// LoadAndValidateConfig loads configuration from the process-global viper
// store, fixes environment variable casing issues, and validates the
// configuration. Returns an error if loading or validation fails.
//
// This is a convenience wrapper around [LoadAndValidateConfigFrom] using the
// shared global store; it is retained for the CLI and other callers that rely
// on viper's process-global state.
func LoadAndValidateConfig() (*Config, error) {
return LoadAndValidateConfigFrom(viper.GetViper())
}
// LoadAndValidateConfigFrom loads configuration from the supplied per-instance
// store, fixes environment variable casing issues, and validates the
// configuration. When v is nil, the process-global store is used. Threading an
// explicit store lets each Kit instance own an isolated configuration without
// clobbering other instances in the same process.
func LoadAndValidateConfigFrom(v *viper.Viper) (*Config, error) {
if v == nil {
v = viper.GetViper()
}
config := &Config{
MCPServers: make(map[string]MCPServerConfig),
}
if err := viper.Unmarshal(config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %v", err)
if err := v.Unmarshal(config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Fix environment variable case sensitivity issue
// Viper lowercases all keys, but we need to preserve the original case for environment variables
fixEnvironmentCase(config)
fixEnvironmentCase(v, config)
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %v", err)
return nil, fmt.Errorf("invalid config: %w", err)
}
return config, nil
}
// fixEnvironmentCase fixes the case of environment variable keys that were lowercased by Viper
func fixEnvironmentCase(config *Config) {
func fixEnvironmentCase(v *viper.Viper, config *Config) {
// Get the raw config data from viper
rawConfig := viper.AllSettings()
rawConfig := v.AllSettings()
// Check if we have mcpServers in the raw config
if mcpServersRaw, ok := rawConfig["mcpservers"]; ok {
-6
View File
@@ -56,9 +56,3 @@ func (e *EnvSubstituter) SubstituteEnvVars(content string) (string, error) {
return result, nil
}
// HasEnvVars checks if content contains environment variable patterns (${env://...}).
// This is useful for determining if substitution is needed before processing.
func HasEnvVars(content string) bool {
return envVarPattern.MatchString(content)
}
-38
View File
@@ -187,41 +187,3 @@ func TestEnvSubstituter_SubstituteEnvVars(t *testing.T) {
})
}
}
func TestHasEnvVars(t *testing.T) {
tests := []struct {
name string
content string
expected bool
}{
{
name: "has env vars",
content: `{"token": "${env://GITHUB_TOKEN}"}`,
expected: true,
},
{
name: "has env vars with default",
content: `{"debug": "${env://DEBUG:-false}"}`,
expected: true,
},
{
name: "no env vars",
content: `{"name": "${username}", "normal": "value"}`,
expected: false,
},
{
name: "empty content",
content: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HasEnvVars(tt.content)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
+57 -78
View File
@@ -59,12 +59,6 @@ func passwordPromptFromContext(ctx context.Context) PasswordPromptCallback {
return nil
}
// ContextWithSudoPassword returns a new context with the sudo password set.
// When present, the bash tool will use sudo -S to pipe this password to sudo commands.
func ContextWithSudoPassword(ctx context.Context, password string) context.Context {
return context.WithValue(ctx, sudoPasswordKey, password)
}
// sudoPasswordFromContext retrieves the sudo password from context.
func sudoPasswordFromContext(ctx context.Context) string {
if pw, ok := ctx.Value(sudoPasswordKey).(string); ok {
@@ -160,15 +154,6 @@ func rewriteSudoForStdin(command string) string {
return result
}
// SudoPasswordRequiredResult is a special marker that indicates sudo needs a password.
// This is stored in tool response metadata to signal the TUI to prompt for password.
const SudoPasswordRequiredMetadata = `{"sudo_password_required":true}`
// IsSudoPasswordRequiredResult checks if a tool response indicates sudo password is needed.
func IsSudoPasswordRequiredResult(resp fantasy.ToolResponse) bool {
return resp.Metadata == SudoPasswordRequiredMetadata
}
func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
var args bashArgs
if err := parseArgs(call.Input, &args); err != nil {
@@ -258,34 +243,37 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
return executeBashBuffered(cmdCtx, call, cmd, sudoPassword)
}
// executeBashBuffered collects all output before returning (original behavior).
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
// close them when grandchild processes hold pipe handles open after the
// direct child exits.
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
// setupBashPipes opens stdout/stderr pipes (plus an optional sudo stdin),
// starts the command, and asynchronously writes the sudo password if any.
// Returns the readers ready for the caller to consume. If setup fails,
// errResp is non-nil and the readers must not be used; the caller should
// return the response directly.
func setupBashPipes(cmd *exec.Cmd, sudoPassword string) (stdout, stderr io.Reader, errResp *fantasy.ToolResponse) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
r := fantasy.NewTextErrorResponse("failed to create stdout pipe")
return nil, nil, &r
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
r := fantasy.NewTextErrorResponse("failed to create stderr pipe")
return nil, nil, &r
}
// If we have a sudo password, create a stdin pipe and write the password
var stdinPipe io.WriteCloser
if sudoPassword != "" {
stdinPipe, err = cmd.StdinPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
r := fantasy.NewTextErrorResponse("failed to create stdin pipe")
return nil, nil, &r
}
}
if err := cmd.Start(); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
r := fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err))
return nil, nil, &r
}
// Write password to stdin if needed, then close stdin
if sudoPassword != "" && stdinPipe != nil {
go func() {
defer func() { _ = stdinPipe.Close() }()
@@ -293,19 +281,49 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
}()
}
return stdoutPipe, stderrPipe, nil
}
// interpretBashExit decodes cmd.Wait()'s error into an exit code, mapping
// context-deadline-exceeded to a friendly "command timed out" response.
// errResp is non-nil only when the caller should short-circuit and return
// it directly (e.g. timeout).
func interpretBashExit(waitErr error, cmdCtx context.Context) (exitCode int, errResp *fantasy.ToolResponse) {
if waitErr == nil {
return 0, nil
}
if exitErr, ok := waitErr.(*exec.ExitError); ok {
return exitErr.ExitCode(), nil
}
if cmdCtx.Err() == context.DeadlineExceeded {
r := fantasy.NewTextErrorResponse("command timed out")
return 0, &r
}
return 0, nil
}
// executeBashBuffered collects all output before returning (original behavior).
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
// close them when grandchild processes hold pipe handles open after the
// direct child exits.
func executeBashBuffered(cmdCtx context.Context, _ fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
if errResp != nil {
return *errResp, nil
}
// Read pipes concurrently
var wg sync.WaitGroup
var stdout, stderr strings.Builder
var stdoutErr, stderrErr error
wg.Add(2)
go func() {
defer wg.Done()
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
_, _ = io.Copy(&stdout, stdoutPipe)
}()
go func() {
defer wg.Done()
_, stderrErr = io.Copy(&stderr, stderrPipe)
_, _ = io.Copy(&stderr, stderrPipe)
}()
// Wait for the process to exit first. cmd.WaitDelay ensures that if
@@ -316,18 +334,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
// Wait for pipe readers to finish draining.
wg.Wait()
// Ignore pipe read errors caused by WaitDelay force-closing —
// we still have whatever was read before the close.
_ = stdoutErr
_ = stderrErr
exitCode := 0
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if cmdCtx.Err() == context.DeadlineExceeded {
return fantasy.NewTextErrorResponse("command timed out"), nil
}
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
if errResp != nil {
return *errResp, nil
}
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
@@ -335,35 +344,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
// executeBashStreaming streams output as it arrives via the callback.
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback, sudoPassword string) (fantasy.ToolResponse, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
}
// If we have a sudo password, create a stdin pipe
var stdinPipe io.WriteCloser
if sudoPassword != "" {
stdinPipe, err = cmd.StdinPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
}
}
// Start command execution
if err := cmd.Start(); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
}
// Write password to stdin if needed, then close stdin
if sudoPassword != "" && stdinPipe != nil {
go func() {
defer func() { _ = stdinPipe.Close() }()
_, _ = io.WriteString(stdinPipe, sudoPassword+"\n")
}()
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
if errResp != nil {
return *errResp, nil
}
// Stream stdout and stderr concurrently
@@ -400,20 +383,16 @@ func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *ex
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
// remain open (held by grandchild processes), they'll be forcibly closed
// after the grace period, which unblocks the scanners above.
err = cmd.Wait()
waitErr := cmd.Wait()
// Wait for the pipe readers to finish draining. This will complete
// quickly since cmd.Wait() (with WaitDelay) has already ensured
// the pipes are closed.
wg.Wait()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if cmdCtx.Err() == context.DeadlineExceeded {
return fantasy.NewTextErrorResponse("command timed out"), nil
}
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
if errResp != nil {
return *errResp, nil
}
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
+1 -1
View File
@@ -183,7 +183,7 @@ func TestRewriteSudoForStdin(t *testing.T) {
func TestSudoPasswordFromContext(t *testing.T) {
// Test with password in context
ctx := ContextWithSudoPassword(context.Background(), "secret123")
ctx := context.WithValue(context.Background(), sudoPasswordKey, "secret123")
pw := sudoPasswordFromContext(ctx)
if pw != "secret123" {
t.Errorf("expected password 'secret123', got %q", pw)
+9 -42
View File
@@ -21,12 +21,9 @@ type Edit struct {
}
// editArgs holds the arguments for the edit tool.
// Supports both single-edit mode (old_text/new_text) and multi-edit mode (edits array).
type editArgs struct {
Path string `json:"path"`
OldText string `json:"old_text"` // Single-edit mode
NewText string `json:"new_text"` // Single-edit mode
Edits []Edit `json:"edits"` // Multi-edit mode
Path string `json:"path"`
Edits []Edit `json:"edits"`
}
// replacement represents a normalized edit ready for processing.
@@ -52,20 +49,12 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "edit",
Description: "Edit a file by replacing exact text. Supports single edit via old_text/new_text, or multiple edits via the edits array. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
Description: "Edit a file by replacing exact text. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to edit (relative or absolute)",
},
"old_text": map[string]any{
"type": "string",
"description": "Exact text to find and replace (single-edit mode). Must not be used with 'edits' array.",
},
"new_text": map[string]any{
"type": "string",
"description": "New text to replace the old text with (single-edit mode). Must not be used with 'edits' array.",
},
"edits": map[string]any{
"type": "array",
"description": "Array of edits for multi-region replacement. Each edit must have unique, non-overlapping old_text. All matches are against the original file content.",
@@ -85,7 +74,7 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
},
},
},
Required: []string{"path"},
Required: []string{"path", "edits"},
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeEdit(ctx, call, cfg.WorkDir)
@@ -94,6 +83,9 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args editArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
@@ -163,36 +155,11 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
// normalizeEditInput validates and normalizes the edit input.
// Returns error if both single-edit and multi-edit modes are used.
func normalizeEditInput(args editArgs) ([]replacement, error) {
singleMode := args.OldText != "" || args.NewText != ""
multiMode := len(args.Edits) > 0
if singleMode && multiMode {
return nil, fmt.Errorf("cannot use old_text/new_text together with edits array")
if len(args.Edits) == 0 {
return nil, fmt.Errorf("edits array is required and must not be empty")
}
if !singleMode && !multiMode {
return nil, fmt.Errorf("must provide either old_text/new_text or edits array")
}
if singleMode {
if args.OldText == "" {
return nil, fmt.Errorf("old_text is required when using single-edit mode")
}
if args.NewText == "" {
return nil, fmt.Errorf("new_text is required when using single-edit mode")
}
return []replacement{{
oldText: strings.ReplaceAll(args.OldText, "\r\n", "\n"),
newText: strings.ReplaceAll(args.NewText, "\r\n", "\n"),
originalOld: args.OldText,
originalNew: args.NewText,
index: 0,
}}, nil
}
// Multi-edit mode
var reps []replacement
for i, edit := range args.Edits {
if edit.OldText == "" {
+62 -44
View File
@@ -389,9 +389,11 @@ func TestExecuteEdit_ExactMatch(t *testing.T) {
writeFileOrFail(t, path, original)
input, _ := json.Marshal(editArgs{
Path: path,
OldText: "fmt.Println(\"hello\")",
NewText: "fmt.Println(\"world\")",
Path: path,
Edits: []Edit{{
OldText: "fmt.Println(\"hello\")",
NewText: "fmt.Println(\"world\")",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -426,9 +428,11 @@ func TestExecuteEdit_ExactMatch_DoesNotCorruptRest(t *testing.T) {
target := lines[49]
replacement := "REPLACED_LINE_50"
input, _ := json.Marshal(editArgs{
Path: path,
OldText: target,
NewText: replacement,
Path: path,
Edits: []Edit{{
OldText: target,
NewText: replacement,
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -470,9 +474,11 @@ func TestExecuteEdit_FuzzyMatch_TrailingWhitespace(t *testing.T) {
// Search without trailing whitespace (common LLM behavior)
input, _ := json.Marshal(editArgs{
Path: path,
OldText: "func foo() {\n\treturn 1\n}",
NewText: "func foo() {\n\treturn 2\n}",
Path: path,
Edits: []Edit{{
OldText: "func foo() {\n\treturn 1\n}",
NewText: "func foo() {\n\treturn 2\n}",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -519,9 +525,11 @@ func TestExecuteEdit_FuzzyMatch_DoesNotCorruptRest(t *testing.T) {
search := strings.Repeat("x", 10) + "\n" + strings.Repeat("x", 10)
// But this matches lines 1-2, 2-3, etc. — should fail due to ambiguity.
input, _ := json.Marshal(editArgs{
Path: path,
OldText: search,
NewText: "REPLACED",
Path: path,
Edits: []Edit{{
OldText: search,
NewText: "REPLACED",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -546,9 +554,11 @@ func TestExecuteEdit_MultipleMatches_Fails(t *testing.T) {
writeFileOrFail(t, path, "hello\nworld\nhello\n")
input, _ := json.Marshal(editArgs{
Path: path,
OldText: "hello",
NewText: "goodbye",
Path: path,
Edits: []Edit{{
OldText: "hello",
NewText: "goodbye",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -575,9 +585,11 @@ func TestExecuteEdit_NoMatch_Fails(t *testing.T) {
writeFileOrFail(t, path, "hello world\n")
input, _ := json.Marshal(editArgs{
Path: path,
OldText: "nonexistent text",
NewText: "replacement",
Path: path,
Edits: []Edit{{
OldText: "nonexistent text",
NewText: "replacement",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -601,9 +613,11 @@ func TestExecuteEdit_CRLFNormalization(t *testing.T) {
writeFileOrFail(t, path, "line1\r\nline2\r\nline3\r\n")
input, _ := json.Marshal(editArgs{
Path: path,
OldText: "line2",
NewText: "LINE2",
Path: path,
Edits: []Edit{{
OldText: "line2",
NewText: "LINE2",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -622,8 +636,10 @@ func TestExecuteEdit_CRLFNormalization(t *testing.T) {
func TestExecuteEdit_MissingPath(t *testing.T) {
input, _ := json.Marshal(editArgs{
OldText: "x",
NewText: "y",
Edits: []Edit{{
OldText: "x",
NewText: "y",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
if err != nil {
@@ -636,9 +652,11 @@ func TestExecuteEdit_MissingPath(t *testing.T) {
func TestExecuteEdit_NonexistentFile(t *testing.T) {
input, _ := json.Marshal(editArgs{
Path: "/tmp/nonexistent_edit_test_file_12345.go",
OldText: "x",
NewText: "y",
Path: "/tmp/nonexistent_edit_test_file_12345.go",
Edits: []Edit{{
OldText: "x",
NewText: "y",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
if err != nil {
@@ -661,9 +679,11 @@ func TestExecuteEdit_DiffContainsHunkHeader(t *testing.T) {
writeFileOrFail(t, path, strings.Join(lines, "\n")+"\n")
input, _ := json.Marshal(editArgs{
Path: path,
OldText: "line_10_content",
NewText: "REPLACED",
Path: path,
Edits: []Edit{{
OldText: "line_10_content",
NewText: "REPLACED",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -684,9 +704,11 @@ func TestExecuteEdit_MetadataContainsFileDiffs(t *testing.T) {
writeFileOrFail(t, path, "old content\n")
input, _ := json.Marshal(editArgs{
Path: path,
OldText: "old content",
NewText: "new content",
Path: path,
Edits: []Edit{{
OldText: "old content",
NewText: "new content",
}},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -905,18 +927,14 @@ func TestExecuteEdit_MultiEdit_EmptyArray(t *testing.T) {
}
}
func TestExecuteEdit_MultiEdit_MixedWithSingleMode(t *testing.T) {
func TestExecuteEdit_EmptyEditsArray_Fails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mixed.txt")
path := filepath.Join(dir, "empty.txt")
writeFileOrFail(t, path, "hello\n")
input, _ := json.Marshal(map[string]any{
"path": path,
"old_text": "hello",
"new_text": "HELLO",
"edits": []Edit{
{OldText: "hello", NewText: "HI"},
},
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
@@ -924,10 +942,10 @@ func TestExecuteEdit_MultiEdit_MixedWithSingleMode(t *testing.T) {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error when mixing single and multi-edit modes")
t.Error("expected error for empty edits array")
}
if !strings.Contains(resp.Content, "cannot use") {
t.Errorf("expected 'cannot use' in error, got: %s", resp.Content)
if !strings.Contains(resp.Content, "required") {
t.Errorf("expected 'required' in error, got: %s", resp.Content)
}
}
+3
View File
@@ -42,6 +42,9 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeLs(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args lsArgs
_ = parseArgs(call.Input, &args) // optional args
+3
View File
@@ -47,6 +47,9 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args readArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
+2 -2
View File
@@ -86,7 +86,7 @@ Example use cases:
},
"model": map[string]any{
"type": "string",
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
"description": "Optional model override. Empty string uses the current model.",
},
"system_prompt": map[string]any{
"type": "string",
@@ -94,7 +94,7 @@ Example use cases:
},
"timeout_seconds": map[string]any{
"type": "number",
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
"description": "Maximum execution time in seconds (default: 300, max: 1800, minimum recommended: 240)",
},
},
Required: []string{"task"},
+3
View File
@@ -41,6 +41,9 @@ func NewWriteTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args writeArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path and content parameters are required"), nil
+234
View File
@@ -0,0 +1,234 @@
package extbridge
import (
"context"
"github.com/mark3labs/kit/internal/extensions"
kit "github.com/mark3labs/kit/pkg/kit"
)
// BaseContext returns an extensions.Context populated with the headless,
// TUI-independent delegation fields: data access, state, options,
// model/tool management, completions, subagents, tree navigation, skills,
// template parsing, and model resolution.
//
// Callers overlay their UI-specific fields (print routes, widgets, prompts,
// editor, TUI-aware SetModel/ReloadExtensions, etc.) on the returned value:
// cmd/extension_context.go for the interactive TUI and
// internal/acpserver/session.go for headless ACP mode. Keeping the shared
// half here means a new data-access Context field only has to be wired once.
//
// ctx is used for subagent spawns; pass a long-lived context (not a
// per-request one) so later spawns aren't cancelled prematurely.
func BaseContext(ctx context.Context, kitInstance *kit.Kit) extensions.Context {
return extensions.Context{
// -------------------------------------------------------------------
// Data access
// -------------------------------------------------------------------
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
return extensions.ContextStats{
EstimatedTokens: s.EstimatedTokens,
ContextLimit: s.ContextLimit,
UsagePercent: s.UsagePercent,
MessageCount: s.MessageCount,
}
},
GetMessages: func() []extensions.SessionMessage {
return kitInstance.Extensions().GetSessionMessages()
},
GetSessionPath: func() string {
return kitInstance.GetSessionPath()
},
AppendEntry: func(entryType string, data string) (string, error) {
return kitInstance.Extensions().AppendEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.Extensions().GetEntries(entryType)
},
// -------------------------------------------------------------------
// Extension state
// -------------------------------------------------------------------
SetState: func(key string, value string) {
kitInstance.Extensions().SetState(key, value)
},
GetState: func(key string) (string, bool) {
return kitInstance.Extensions().GetState(key)
},
DeleteState: func(key string) {
kitInstance.Extensions().DeleteState(key)
},
ListState: func() []string {
return kitInstance.Extensions().ListState()
},
// -------------------------------------------------------------------
// Options, model, and tool management
// -------------------------------------------------------------------
GetOption: func(name string) string {
return kitInstance.Extensions().GetOption(name)
},
SetOption: func(name string, value string) {
kitInstance.Extensions().SetOption(name, value)
},
// Headless model switch. The interactive TUI overrides this with a
// version that also notifies the TUI and refreshes the usage tracker.
SetModel: func(modelString string) error {
previousModel := kitInstance.Extensions().GetContext().Model
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
return err
}
kitInstance.Extensions().UpdateContextModel(modelString)
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry {
return kitInstance.GetAvailableModels()
},
EmitCustomEvent: func(name string, data string) {
kitInstance.Extensions().EmitCustomEvent(name, data)
},
GetAllTools: func() []extensions.ToolInfo {
return kitInstance.Extensions().GetToolInfos()
},
SetActiveTools: func(names []string) {
kitInstance.Extensions().SetActiveTools(names)
},
// Headless reload. The interactive TUI overrides this to also
// refresh widgets/status/commands.
ReloadExtensions: func() error {
return kitInstance.Extensions().Reload()
},
// -------------------------------------------------------------------
// LLM completions and subagents
// -------------------------------------------------------------------
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
},
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
return SpawnSubagent(ctx, kitInstance, config)
},
// -------------------------------------------------------------------
// Tree Navigation API
// -------------------------------------------------------------------
GetTreeNode: func(entryID string) *extensions.TreeNode {
node := kitInstance.GetTreeNode(entryID)
if node == nil {
return nil
}
return &extensions.TreeNode{
ID: node.ID,
ParentID: node.ParentID,
Type: node.Type,
Role: node.Role,
Content: node.Content,
Model: node.Model,
Provider: node.Provider,
Timestamp: node.Timestamp,
Children: node.Children,
}
},
GetCurrentBranch: func() []extensions.TreeNode {
nodes := kitInstance.GetCurrentBranch()
result := make([]extensions.TreeNode, len(nodes))
for i, n := range nodes {
result[i] = extensions.TreeNode{
ID: n.ID,
ParentID: n.ParentID,
Type: n.Type,
Role: n.Role,
Content: n.Content,
Model: n.Model,
Provider: n.Provider,
Timestamp: n.Timestamp,
Children: n.Children,
}
}
return result
},
GetChildren: func(parentID string) []string {
return kitInstance.GetChildren(parentID)
},
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
err := kitInstance.NavigateTo(entryID)
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
SummarizeBranch: func(fromID, toID string) string {
summary, _ := kitInstance.SummarizeBranch(fromID, toID)
return summary
},
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
err := kitInstance.CollapseBranch(fromID, toID, summary)
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
// -------------------------------------------------------------------
// Skill Loading API (context-injection variants are TUI-specific and
// wired by the interactive overlay)
// -------------------------------------------------------------------
LoadSkill: func(path string) (*extensions.Skill, string) {
s, err := kitInstance.LoadSkillForExtension(path)
return s, err
},
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
return kitInstance.LoadSkillsFromDirForExtension(dir)
},
DiscoverSkills: func() extensions.SkillLoadResult {
skills := kitInstance.DiscoverSkillsForExtension()
return extensions.SkillLoadResult{Skills: skills}
},
GetAvailableSkills: func() []extensions.Skill {
return kitInstance.DiscoverSkillsForExtension()
},
// -------------------------------------------------------------------
// Template Parsing API
// -------------------------------------------------------------------
ParseTemplate: func(name, content string) extensions.PromptTemplate {
return kit.ParseTemplate(name, content)
},
RenderTemplate: func(tpl extensions.PromptTemplate, vars map[string]string) string {
return kit.RenderTemplate(tpl, vars)
},
ParseArguments: func(input string, pattern extensions.ArgumentPattern) extensions.ParseResult {
return kit.ParseArguments(input, pattern)
},
SimpleParseArguments: func(input string, count int) []string {
return kit.SimpleParseArguments(input, count)
},
EvaluateModelConditional: func(condition string) bool {
return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition)
},
RenderWithModelConditionals: func(content string) string {
return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model)
},
// -------------------------------------------------------------------
// Model Resolution API
// -------------------------------------------------------------------
ResolveModelChain: func(preferences []string) extensions.ModelResolutionResult {
return kit.ResolveModelChain(preferences)
},
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
return kit.GetModelCapabilities(model)
},
CheckModelAvailable: func(model string) bool {
return kit.CheckModelAvailable(model)
},
GetCurrentProvider: func() string {
return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model)
},
GetCurrentModelID: func() string {
return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model)
},
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package extbridge wires the public Kit SDK to the internal extensions
// package. It exists so that cmd/ and internal/acpserver/ don't both
// reimplement the same SDK→extension event/subagent conversions.
package extbridge
import (
"context"
"github.com/mark3labs/kit/internal/extensions"
kit "github.com/mark3labs/kit/pkg/kit"
)
// SDKEventToSubagentEvent converts an SDK [kit.Event] into the
// extension-facing [extensions.SubagentEvent]. Returns a zero-value event
// (Type=="") for events that don't map to anything useful — callers should
// drop those.
func SDKEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
switch ev := e.(type) {
case kit.MessageUpdateEvent:
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
case kit.ReasoningDeltaEvent:
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
case kit.ToolCallEvent:
return extensions.SubagentEvent{
Type: "tool_call", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
}
case kit.ToolExecutionStartEvent:
return extensions.SubagentEvent{
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolExecutionEndEvent:
return extensions.SubagentEvent{
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
}
case kit.ToolResultEvent:
return extensions.SubagentEvent{
Type: "tool_result", ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
ToolResult: ev.Result, IsError: ev.IsError,
}
case kit.TurnStartEvent:
return extensions.SubagentEvent{Type: "turn_start"}
case kit.TurnEndEvent:
return extensions.SubagentEvent{Type: "turn_end"}
default:
return extensions.SubagentEvent{}
}
}
// SpawnSubagent runs a subagent in-process via the Kit SDK and translates
// the result/events back into the extension-facing types. The returned
// handle is always nil — the SDK path runs synchronously and does not
// expose a separate process handle. Callers that need non-blocking
// behaviour should run this in their own goroutine.
//
// This function consolidates the previously-duplicated wiring in
// cmd/root.go (interactive + runtime contexts) and
// internal/acpserver/session.go.
func SpawnSubagent(ctx context.Context, k *kit.Kit, cfg extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
sdkCfg := kit.SubagentConfig{
Prompt: cfg.Prompt,
Model: cfg.Model,
SystemPrompt: cfg.SystemPrompt,
Timeout: cfg.Timeout,
NoSession: cfg.NoSession,
Tools: k.GetToolsForSubagent(),
}
if cfg.OnEvent != nil {
sdkCfg.OnEvent = func(e kit.Event) {
se := SDKEventToSubagentEvent(e)
if se.Type != "" {
cfg.OnEvent(se)
}
}
}
result, err := k.Subagent(ctx, sdkCfg)
if result == nil {
return nil, &extensions.SubagentResult{Error: err}, err
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
if result.Usage != nil {
extResult.Usage = &extensions.SubagentUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
}
}
return nil, extResult, err
}
+336 -1
View File
@@ -341,6 +341,13 @@ type Context struct {
// The data survives across session restarts and can be retrieved via
// GetEntries. Use entryType to namespace your data (e.g. "myext:state").
//
// AppendEntry is append-only and lives in the conversation tree, which
// makes it the right tool for audit logs and event histories. For
// last-write-wins snapshot state — "what's the current value of X?" —
// prefer SetState / GetState instead. Those primitives store data in a
// sidecar file outside the conversation tree, are O(1) to read/write,
// and do not bloat branch reads or duplicate on fork.
//
// Example:
//
// data, _ := json.Marshal(myState)
@@ -360,6 +367,45 @@ type Context struct {
// }
GetEntries func(entryType string) []ExtensionEntry
// SetState stores a key-value pair in session-scoped, last-write-wins
// extension state. Unlike AppendEntry the value is kept in a sidecar
// file outside the conversation tree, so:
// - reads are O(1) (no branch walk)
// - writes don't bloat the session JSONL
// - state is not duplicated on fork (branches share the sidecar)
// - state is invisible to the LLM
//
// Use SetState for snapshot state ("current value of X"); use
// AppendEntry for audit logs and event histories. Namespace keys with
// your extension name to avoid collisions (e.g. "myext:budget-cap").
//
// State persists for the lifetime of the session. For ephemeral or
// in-memory sessions the state lives only in memory.
//
// Example:
//
// ctx.SetState("myext:budget-cap", "10.00")
SetState func(key string, value string)
// GetState returns the value previously stored via SetState. The bool
// is false when the key was never written. Returns ("", false) when
// state is unavailable.
//
// Example:
//
// if cap, ok := ctx.GetState("myext:budget-cap"); ok {
// fmt.Println("current cap:", cap)
// }
GetState func(key string) (string, bool)
// DeleteState removes a key from session-scoped extension state.
// No-op when the key is missing.
DeleteState func(key string)
// ListState returns all keys currently stored in session-scoped
// extension state, in unspecified order.
ListState func() []string
// SetEditorText sets the text content of the input editor. This can
// be used to pre-fill the editor with suggested text (e.g. extracted
// questions, handoff prompts). The cursor is moved to the end.
@@ -1063,6 +1109,9 @@ type PrintBlockOpts struct {
type API struct {
// Event-specific registration functions (wired by the loader).
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
onToolCallInputStart func(func(ToolCallInputStartEvent, Context))
onToolCallInputDelta func(func(ToolCallInputDeltaEvent, Context))
onToolCallInputEnd func(func(ToolCallInputEndEvent, Context))
onToolExecStart func(func(ToolExecutionStartEvent, Context))
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
onToolOutput func(func(ToolOutputEvent, Context))
@@ -1091,6 +1140,15 @@ type API struct {
onSubagentStart func(func(SubagentStartEvent, Context))
onSubagentChunk func(func(SubagentChunkEvent, Context))
onSubagentEnd func(func(SubagentEndEvent, Context))
onStepStart func(func(StepStartEvent, Context))
onStepFinish func(func(StepFinishEvent, Context))
onReasoningStart func(func(ReasoningStartEvent, Context))
onWarnings func(func(WarningsEvent, Context))
onSource func(func(SourceEvent, Context))
onError func(func(ErrorEvent, Context))
onRetry func(func(RetryEvent, Context))
onPrepareStep func(func(PrepareStepEvent, Context) *PrepareStepResult)
onLLMUsage func(func(LLMUsageEvent, Context))
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -1099,6 +1157,26 @@ func (a *API) OnToolCall(handler func(ToolCallEvent, Context) *ToolCallResult) {
a.onToolCall(handler)
}
// OnToolCallInputStart registers a handler that fires when the LLM begins
// generating tool call arguments. The tool name is known but the full
// argument JSON is still being streamed. Useful for showing a "running"
// indicator immediately without waiting for the full arguments.
func (a *API) OnToolCallInputStart(handler func(ToolCallInputStartEvent, Context)) {
a.onToolCallInputStart(handler)
}
// OnToolCallInputDelta registers a handler that fires for each streamed
// fragment of tool call arguments as they arrive from the LLM.
func (a *API) OnToolCallInputDelta(handler func(ToolCallInputDeltaEvent, Context)) {
a.onToolCallInputDelta(handler)
}
// OnToolCallInputEnd registers a handler that fires when tool argument
// streaming is complete, before the tool call is parsed and execution begins.
func (a *API) OnToolCallInputEnd(handler func(ToolCallInputEndEvent, Context)) {
a.onToolCallInputEnd(handler)
}
// OnToolExecutionStart registers a handler for tool execution start.
func (a *API) OnToolExecutionStart(handler func(ToolExecutionStartEvent, Context)) {
a.onToolExecStart(handler)
@@ -1278,6 +1356,69 @@ func (a *API) OnBeforeCompact(handler func(BeforeCompactEvent, Context) *BeforeC
a.onBeforeCompact(handler)
}
// OnStepStart registers a handler that fires when a new LLM call begins
// within a multi-step agent turn.
func (a *API) OnStepStart(handler func(StepStartEvent, Context)) {
a.onStepStart(handler)
}
// OnStepFinish registers a handler that fires when a step completes,
// providing step number, finish reason, and decomposed token usage.
func (a *API) OnStepFinish(handler func(StepFinishEvent, Context)) {
a.onStepFinish(handler)
}
// OnReasoningStart registers a handler that fires when the LLM begins
// reasoning/thinking.
func (a *API) OnReasoningStart(handler func(ReasoningStartEvent, Context)) {
a.onReasoningStart(handler)
}
// OnWarnings registers a handler that fires when the LLM provider returns
// warnings about the request.
func (a *API) OnWarnings(handler func(WarningsEvent, Context)) {
a.onWarnings(handler)
}
// OnSource registers a handler that fires when the LLM references a source
// (e.g. from web search tools).
func (a *API) OnSource(handler func(SourceEvent, Context)) {
a.onSource(handler)
}
// OnError registers a handler that fires when an agent-level error occurs
// during streaming.
func (a *API) OnError(handler func(ErrorEvent, Context)) {
a.onError(handler)
}
// OnRetry registers a handler that fires when the LLM provider request is
// retried after a transient error.
func (a *API) OnRetry(handler func(RetryEvent, Context)) {
a.onRetry(handler)
}
// OnPrepareStep registers a handler that fires between steps within a
// multi-step agent turn, after steering messages are injected and before
// messages are sent to the LLM. Return a non-nil PrepareStepResult with
// Messages to replace the context window for this step.
func (a *API) OnPrepareStep(handler func(PrepareStepEvent, Context) *PrepareStepResult) {
a.onPrepareStep(handler)
}
// OnLLMUsage registers a handler that fires after each LLM provider call
// with the token and cost deltas for that single call. Use this for
// per-call usage attribution, real-time budget enforcement, and cost
// dashboards that need to react between calls within a single agent turn.
//
// Handlers receive an LLMUsageEvent describing the call's input/output
// tokens, cache tokens, computed cost, model, and provider. A single agent
// turn typically fires multiple LLMUsageEvents (one per tool-loop
// iteration).
func (a *API) OnLLMUsage(handler func(LLMUsageEvent, Context)) {
a.onLLMUsage(handler)
}
// RegisterToolRenderer registers a custom renderer for a specific tool's
// display in the TUI. The renderer controls the header (parameter summary)
// and/or body (result display) of the tool's output block. If multiple
@@ -1890,6 +2031,34 @@ type ToolCallResult struct {
func (ToolCallResult) isResult() {}
// ToolCallInputStartEvent fires when the LLM begins generating tool call
// arguments. The tool name is known but the full argument JSON is still
// being streamed.
type ToolCallInputStartEvent struct {
ToolCallID string
ToolName string
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
}
func (e ToolCallInputStartEvent) Type() EventType { return ToolCallInputStart }
// ToolCallInputDeltaEvent fires for each streamed fragment of tool call
// arguments as they arrive from the LLM.
type ToolCallInputDeltaEvent struct {
ToolCallID string
Delta string // JSON fragment of tool arguments
}
func (e ToolCallInputDeltaEvent) Type() EventType { return ToolCallInputDelta }
// ToolCallInputEndEvent fires when tool argument streaming is complete,
// before the tool call is parsed and execution begins.
type ToolCallInputEndEvent struct {
ToolCallID string
}
func (e ToolCallInputEndEvent) Type() EventType { return ToolCallInputEnd }
// ToolExecutionStartEvent fires when a tool begins executing.
type ToolExecutionStartEvent struct {
ToolCallID string
@@ -1982,10 +2151,47 @@ type AgentStartEvent struct {
func (e AgentStartEvent) Type() EventType { return AgentStart }
// AgentEndEvent fires when the agent finishes responding.
// AgentEndEvent fires when the agent finishes responding. In addition to the
// final response and stop reason, the event carries per-turn aggregates so
// observer-style extensions don't have to maintain parallel bookkeeping in
// OnToolResult / OnStepFinish handlers.
type AgentEndEvent struct {
Response string
StopReason string // "completed", "cancelled", "error"
// ToolCallCount is the total number of tool invocations observed during
// this turn (sum across all steps).
ToolCallCount int
// ToolNames lists the tool names invoked during this turn, in call order.
// Duplicates are preserved (e.g. two bash calls produce ["bash", "bash"]).
ToolNames []string
// LLMCallCount is the number of LLM round-trips (tool-loop iterations)
// performed during this turn. Always >= 1 for a successful turn.
LLMCallCount int
// InputTokensDelta is the sum of input tokens consumed during this turn
// across every LLM call (including cache-hit input tokens).
InputTokensDelta int
// OutputTokensDelta is the sum of output tokens generated during this turn.
OutputTokensDelta int
// CacheReadTokensDelta is the sum of cache-read tokens during this turn.
CacheReadTokensDelta int
// CacheWriteTokensDelta is the sum of cache-write tokens during this turn.
CacheWriteTokensDelta int
// CostDelta is the total cost in USD attributable to this turn. Computed
// from per-step usage and current model pricing. Zero when pricing is
// unknown or OAuth credentials are in use.
CostDelta float64
// DurationMs is the elapsed wall-clock time from AgentStart to AgentEnd,
// in milliseconds.
DurationMs int64
}
func (e AgentEndEvent) Type() EventType { return AgentEnd }
@@ -2202,6 +2408,135 @@ type SubagentEndEvent struct {
func (e SubagentEndEvent) Type() EventType { return SubagentEnd }
// ---------------------------------------------------------------------------
// Step lifecycle events (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// StepStartEvent fires when a new LLM call begins within a multi-step agent turn.
type StepStartEvent struct {
StepNumber int
}
func (e StepStartEvent) Type() EventType { return StepStart }
// StepFinishEvent fires when a step completes, providing step metadata and
// token usage. Usage fields are plain int64 (not LLMUsage) because Yaegi
// cannot handle fantasy types across the interpreter boundary.
type StepFinishEvent struct {
StepNumber int
HasToolCalls bool
FinishReason string
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheWriteTokens int64
}
func (e StepFinishEvent) Type() EventType { return StepFinish }
// ReasoningStartEvent fires when the LLM begins reasoning/thinking.
type ReasoningStartEvent struct {
ID string
}
func (e ReasoningStartEvent) Type() EventType { return ReasoningStart }
// WarningsEvent fires when the LLM provider returns warnings about the request.
type WarningsEvent struct {
Warnings []string
}
func (e WarningsEvent) Type() EventType { return Warnings }
// SourceEvent fires when the LLM references a source (e.g. from web search).
type SourceEvent struct {
SourceType string
ID string
URL string
Title string
}
func (e SourceEvent) Type() EventType { return Source }
// ErrorEvent fires when an agent-level error occurs during streaming.
// Uses string instead of error because Yaegi cannot handle the error
// interface reliably across the interpreter boundary.
type ErrorEvent struct {
Error string
}
func (e ErrorEvent) Type() EventType { return Error }
// RetryEvent fires when the LLM provider request is retried after a
// transient error.
type RetryEvent struct {
Attempt int
Error string
}
func (e RetryEvent) Type() EventType { return Retry }
// PrepareStepEvent fires between steps within a multi-step agent turn,
// after steering messages are injected and before messages are sent to
// the LLM. Handlers can inspect and replace the context window.
type PrepareStepEvent struct {
// StepNumber is the zero-based step index within the current turn.
StepNumber int
// Messages is the current context window that will be sent to the LLM.
Messages []ContextMessage
}
func (e PrepareStepEvent) Type() EventType { return PrepareStep }
// PrepareStepResult allows extensions to replace the context window between
// steps. Return nil Messages to leave the context unchanged.
type PrepareStepResult struct {
// Messages replaces the entire context window for this step. If nil,
// the original messages are used unchanged. Messages with a non-negative
// Index reuse the original message at that position; messages with
// Index < 0 are created fresh from Role + Content.
Messages []ContextMessage
}
func (PrepareStepResult) isResult() {}
// LLMUsageEvent fires after each LLM provider call with the per-call token
// and cost deltas. Use this for accurate budget tracking, cost dashboards,
// and any logic that needs to react between LLM calls within a single agent
// turn (rather than only at turn boundaries).
//
// A single agent turn typically produces multiple LLMUsageEvents (one per
// tool-loop iteration). The Model and Provider fields reflect the model used
// for that specific call, which may differ from earlier calls if the
// extension switched models mid-turn via ctx.SetModel().
type LLMUsageEvent struct {
// InputTokens is the number of input tokens for this call.
InputTokens int
// OutputTokens is the number of output tokens generated by this call.
OutputTokens int
// CacheReadTokens is the number of cache-hit input tokens (provider-specific).
CacheReadTokens int
// CacheWriteTokens is the number of cache-write tokens.
CacheWriteTokens int
// Cost is the USD cost of this call computed from the model's per-token
// pricing. Zero when pricing is unknown or OAuth credentials are in use.
Cost float64
// Model is the model identifier used for this call (e.g. "claude-sonnet-4-5-20250929").
Model string
// Provider is the provider identifier (e.g. "anthropic", "openai").
Provider string
// RequestID is an optional correlation id for the underlying provider
// call. May be empty when the provider does not surface one.
RequestID string
// StepNumber is the zero-based step index within the current agent turn.
StepNumber int
// FinishReason mirrors the provider's finish reason for this call
// (e.g. "stop", "tool_calls", "length"). May be empty.
FinishReason string
}
func (e LLMUsageEvent) Type() EventType { return LLMUsage }
// ThemeColor is an adaptive color pair with light and dark hex values.
// Either field may be empty to inherit from the default theme.
type ThemeColor struct {
+51 -1
View File
@@ -13,6 +13,19 @@ const (
// ToolCall fires before a tool executes. Handlers can block execution.
ToolCall EventType = "tool_call"
// ToolCallInputStart fires when the LLM begins generating tool call
// arguments. The tool name is known but the full argument JSON is still
// being streamed.
ToolCallInputStart EventType = "tool_call_input_start"
// ToolCallInputDelta fires for each streamed fragment of tool call
// arguments as they arrive from the LLM.
ToolCallInputDelta EventType = "tool_call_input_delta"
// ToolCallInputEnd fires when tool argument streaming is complete,
// before the tool call is parsed and execution begins.
ToolCallInputEnd EventType = "tool_call_input_end"
// ToolExecutionStart fires when a tool begins executing.
ToolExecutionStart EventType = "tool_execution_start"
@@ -83,18 +96,55 @@ const (
// SubagentEnd fires when a subagent tool call completes (success
// or error). Carries the final response and any error message.
SubagentEnd EventType = "subagent_end"
// StepStart fires when a new LLM call begins within a multi-step
// agent turn.
StepStart EventType = "step_start"
// StepFinish fires when a step completes, providing step number,
// finish reason, and token usage.
StepFinish EventType = "step_finish"
// ReasoningStart fires when the LLM begins reasoning/thinking.
ReasoningStart EventType = "reasoning_start"
// Warnings fires when the LLM provider returns warnings.
Warnings EventType = "warnings"
// Source fires when the LLM references a source (e.g. web search).
Source EventType = "source"
// Error fires when an agent-level error occurs during streaming.
Error EventType = "error"
// Retry fires when the LLM provider request is retried after a
// transient error.
Retry EventType = "retry"
// PrepareStep fires between steps within a multi-step agent turn,
// after steering messages are injected and before messages are sent
// to the LLM. Handlers can replace the context window for this step.
PrepareStep EventType = "prepare_step"
// LLMUsage fires after each LLM provider call with the token and cost
// deltas for that single call. Extensions use it to attribute usage to
// specific calls/models and to drive budget enforcement between calls.
LLMUsage EventType = "llm_usage"
)
// AllEventTypes returns every supported event type.
func AllEventTypes() []EventType {
return []EventType{
ToolCall, ToolExecutionStart, ToolExecutionEnd, ToolResult,
ToolCall, ToolCallInputStart, ToolCallInputDelta, ToolCallInputEnd,
ToolExecutionStart, ToolExecutionEnd, ToolResult,
Input, BeforeAgentStart, AgentStart, AgentEnd,
MessageStart, MessageUpdate, MessageEnd,
SessionStart, SessionShutdown,
ModelChange, ContextPrepare,
BeforeFork, BeforeSessionSwitch, BeforeCompact,
SubagentStart, SubagentChunk, SubagentEnd,
StepStart, StepFinish, ReasoningStart, Warnings, Source, Error, Retry,
PrepareStep, LLMUsage,
}
}
+5 -2
View File
@@ -4,8 +4,8 @@ import "testing"
func TestAllEventTypes_Count(t *testing.T) {
all := AllEventTypes()
if len(all) != 21 {
t.Fatalf("expected 21 event types, got %d", len(all))
if len(all) != 33 {
t.Fatalf("expected 33 event types, got %d", len(all))
}
}
@@ -38,6 +38,9 @@ func TestEventType_TypeMethod(t *testing.T) {
want EventType
}{
{ToolCallEvent{ToolName: "test"}, ToolCall},
{ToolCallInputStartEvent{ToolCallID: "x", ToolName: "test"}, ToolCallInputStart},
{ToolCallInputDeltaEvent{ToolCallID: "x", Delta: "{"}, ToolCallInputDelta},
{ToolCallInputEndEvent{ToolCallID: "x"}, ToolCallInputEnd},
{ToolExecutionStartEvent{ToolName: "test"}, ToolExecutionStart},
{ToolExecutionEndEvent{ToolName: "test"}, ToolExecutionEnd},
{ToolResultEvent{ToolName: "test"}, ToolResult},
-19
View File
@@ -450,25 +450,6 @@ func globalGitInstallRoot() string {
return filepath.Join(base, "kit", "git")
}
// GetInstalledPackages returns all installed packages from both scopes.
func (i *Installer) GetInstalledPackages() ([]ManifestEntry, error) {
var all []ManifestEntry
global, err := i.loadManifest(ScopeGlobal)
if err != nil {
return nil, fmt.Errorf("loading global manifest: %w", err)
}
all = append(all, global.Packages...)
project, err := i.loadManifest(ScopeProject)
if err != nil {
return nil, fmt.Errorf("loading project manifest: %w", err)
}
all = append(all, project.Packages...)
return all, nil
}
// IsInstalled checks if a package is installed in either scope.
// Returns (scope, true) if installed, ("", false) otherwise.
func (i *Installer) IsInstalled(source *GitSource) (InstallScope, bool) {
+45 -46
View File
@@ -245,14 +245,21 @@ func TestManifestEntryIdentity(t *testing.T) {
}
}
// TestLoadAndSaveManifest exercises the live *Installer.loadManifest /
// saveManifest round-trip against a temp directory, ensuring an absent
// manifest loads as empty and a saved manifest reads back identically.
func TestLoadAndSaveManifest(t *testing.T) {
tempDir := t.TempDir()
installer := &Installer{
projectGitRoot: tempDir,
globalGitRoot: tempDir,
}
manifestPath := filepath.Join(tempDir, "packages.json")
// Test loading non-existent manifest
manifest, err := loadManifestFromPath(manifestPath)
manifest, err := installer.loadManifest(ScopeGlobal)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
t.Fatalf("loadManifest() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected empty packages, got %d", len(manifest.Packages))
@@ -273,15 +280,20 @@ func TestLoadAndSaveManifest(t *testing.T) {
}
// Save it
err = saveManifestToPath(manifest, manifestPath)
err = installer.saveManifest(manifest, ScopeGlobal)
if err != nil {
t.Fatalf("saveManifestToPath() error = %v", err)
t.Fatalf("saveManifest() error = %v", err)
}
// Verify it was written to expected path
if _, err := os.Stat(manifestPath); err != nil {
t.Fatalf("manifest file not created: %v", err)
}
// Load it back
loaded, err := loadManifestFromPath(manifestPath)
loaded, err := installer.loadManifest(ScopeGlobal)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
t.Fatalf("loadManifest() error = %v", err)
}
if len(loaded.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(loaded.Packages))
@@ -291,21 +303,15 @@ func TestLoadAndSaveManifest(t *testing.T) {
}
}
// TestAddAndRemoveFromManifest verifies that *Installer.addToManifest
// followed by removeFromManifest leaves the manifest in its original
// (empty) state, using a temp-directory installer scope.
func TestAddAndRemoveFromManifest(t *testing.T) {
tempDir := t.TempDir()
// Set up environment for manifest path
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
installer := &Installer{
projectGitRoot: tempDir,
globalGitRoot: tempDir,
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
// The manifest path when XDG_DATA_HOME is set
manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json")
// Add an entry
entry := ManifestEntry{
@@ -315,58 +321,51 @@ func TestAddAndRemoveFromManifest(t *testing.T) {
Scope: ScopeGlobal,
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
if err := installer.addToManifest(entry, ScopeGlobal); err != nil {
t.Fatalf("addToManifest() error = %v", err)
}
// Verify it was added
manifest, err := loadManifestFromPath(manifestPath)
manifest, err := installer.loadManifest(ScopeGlobal)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
t.Fatalf("loadManifest() error = %v", err)
}
if len(manifest.Packages) != 1 {
t.Errorf("Expected 1 package, got %d", len(manifest.Packages))
}
// Remove it
err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal)
if err != nil {
t.Fatalf("removeEntryFromManifest() error = %v", err)
if err := installer.removeFromManifest("github.com/user/repo", ScopeGlobal); err != nil {
t.Fatalf("removeFromManifest() error = %v", err)
}
// Verify it was removed
manifest, err = loadManifestFromPath(manifestPath)
manifest, err = installer.loadManifest(ScopeGlobal)
if err != nil {
t.Fatalf("loadManifestFromPath() error = %v", err)
t.Fatalf("loadManifest() error = %v", err)
}
if len(manifest.Packages) != 0 {
t.Errorf("Expected 0 packages, got %d", len(manifest.Packages))
}
}
// TestFindInManifest writes a manifest file directly to the path
// resolved by the package-level manifestPathForScope helper and then
// confirms FindInManifest locates the entry by identity (and returns
// nil for a non-existent identity).
func TestFindInManifest(t *testing.T) {
tempDir := t.TempDir()
if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil {
t.Fatalf("Setenv() error = %v", err)
}
defer func() {
if err := os.Unsetenv("XDG_DATA_HOME"); err != nil {
t.Logf("Unsetenv() error = %v", err)
}
}()
t.Setenv("XDG_DATA_HOME", tempDir)
// Add an entry to global manifest
entry := ManifestEntry{
Source: "git:github.com/user/repo",
Host: "github.com",
Path: "user/repo",
Scope: ScopeGlobal,
// Write a manifest entry directly via the package-level path resolver
// so FindInManifest (which uses manifestPathForScope) can read it back.
manifestPath := manifestPathForScope(ScopeGlobal)
if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
err := addEntryToManifest(entry, ScopeGlobal)
if err != nil {
t.Fatalf("addEntryToManifest() error = %v", err)
data := []byte(`{"packages":[{"source":"git:github.com/user/repo","repo":"","host":"github.com","path":"user/repo","pinned":false,"scope":"global","installed":"0001-01-01T00:00:00Z"}]}`)
if err := os.WriteFile(manifestPath, data, 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
// Find it
+119
View File
@@ -0,0 +1,119 @@
package extensions
import "testing"
func TestRunner_EmitLLMUsage(t *testing.T) {
var got LLMUsageEvent
var called bool
ext := makeHandlerExt("llmusage.go", map[EventType][]HandlerFunc{
LLMUsage: {
func(e Event, c Context) Result {
got = e.(LLMUsageEvent)
called = true
return nil
},
},
})
r := makeRunner(ext)
_, err := r.Emit(LLMUsageEvent{
InputTokens: 100,
OutputTokens: 50,
Cost: 0.0012,
Model: "claude-sonnet-4-5-20250929",
Provider: "anthropic",
StepNumber: 2,
FinishReason: "tool_calls",
})
if err != nil {
t.Fatalf("emit: %v", err)
}
if !called {
t.Fatal("expected LLMUsage handler to be called")
}
if got.InputTokens != 100 || got.OutputTokens != 50 {
t.Errorf("token fields not propagated: %+v", got)
}
if got.Cost != 0.0012 {
t.Errorf("cost not propagated, got %v", got.Cost)
}
if got.Model != "claude-sonnet-4-5-20250929" || got.Provider != "anthropic" {
t.Errorf("model/provider not propagated: %+v", got)
}
if got.StepNumber != 2 || got.FinishReason != "tool_calls" {
t.Errorf("step/finish reason not propagated: %+v", got)
}
}
func TestRunner_LLMUsageRegisteredViaTestAPI(t *testing.T) {
// Verify NewTestAPI wires up onLLMUsage so the extension can call
// api.OnLLMUsage during Init.
ext := &LoadedExtension{Handlers: make(map[EventType][]HandlerFunc)}
api := NewTestAPI(ext)
var calls int
api.OnLLMUsage(func(e LLMUsageEvent, c Context) {
calls++
})
if len(ext.Handlers[LLMUsage]) != 1 {
t.Fatalf("expected 1 LLMUsage handler registered, got %d", len(ext.Handlers[LLMUsage]))
}
r := makeRunner(*ext)
_, _ = r.Emit(LLMUsageEvent{InputTokens: 1})
if calls != 1 {
t.Errorf("expected handler called once, got %d", calls)
}
}
func TestAgentEndEvent_EnrichedFields(t *testing.T) {
// Verify the enriched event carries through Emit without mangling.
var got AgentEndEvent
ext := makeHandlerExt("end.go", map[EventType][]HandlerFunc{
AgentEnd: {
func(e Event, c Context) Result {
got = e.(AgentEndEvent)
return nil
},
},
})
r := makeRunner(ext)
_, err := r.Emit(AgentEndEvent{
Response: "done",
StopReason: "completed",
ToolCallCount: 3,
ToolNames: []string{"bash", "read", "bash"},
LLMCallCount: 4,
InputTokensDelta: 1500,
OutputTokensDelta: 400,
CacheReadTokensDelta: 200,
CacheWriteTokensDelta: 100,
CostDelta: 0.0123,
DurationMs: 2500,
})
if err != nil {
t.Fatalf("emit: %v", err)
}
if got.ToolCallCount != 3 {
t.Errorf("ToolCallCount: got %d want 3", got.ToolCallCount)
}
if len(got.ToolNames) != 3 || got.ToolNames[0] != "bash" || got.ToolNames[2] != "bash" {
t.Errorf("ToolNames: %v", got.ToolNames)
}
if got.LLMCallCount != 4 {
t.Errorf("LLMCallCount: got %d want 4", got.LLMCallCount)
}
if got.InputTokensDelta != 1500 || got.OutputTokensDelta != 400 {
t.Errorf("token deltas: %+v", got)
}
if got.CacheReadTokensDelta != 200 || got.CacheWriteTokensDelta != 100 {
t.Errorf("cache deltas: %+v", got)
}
if got.CostDelta != 0.0123 {
t.Errorf("CostDelta: got %v", got.CostDelta)
}
if got.DurationMs != 2500 {
t.Errorf("DurationMs: got %d", got.DurationMs)
}
}
+75
View File
@@ -429,6 +429,24 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
return *r
})
},
onToolCallInputStart: func(h func(ToolCallInputStartEvent, Context)) {
reg(ToolCallInputStart, func(e Event, c Context) Result {
h(e.(ToolCallInputStartEvent), c)
return nil
})
},
onToolCallInputDelta: func(h func(ToolCallInputDeltaEvent, Context)) {
reg(ToolCallInputDelta, func(e Event, c Context) Result {
h(e.(ToolCallInputDeltaEvent), c)
return nil
})
},
onToolCallInputEnd: func(h func(ToolCallInputEndEvent, Context)) {
reg(ToolCallInputEnd, func(e Event, c Context) Result {
h(e.(ToolCallInputEndEvent), c)
return nil
})
},
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
reg(ToolExecutionStart, func(e Event, c Context) Result {
h(e.(ToolExecutionStartEvent), c)
@@ -600,6 +618,63 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
return nil
})
},
onStepStart: func(h func(StepStartEvent, Context)) {
reg(StepStart, func(e Event, c Context) Result {
h(e.(StepStartEvent), c)
return nil
})
},
onStepFinish: func(h func(StepFinishEvent, Context)) {
reg(StepFinish, func(e Event, c Context) Result {
h(e.(StepFinishEvent), c)
return nil
})
},
onReasoningStart: func(h func(ReasoningStartEvent, Context)) {
reg(ReasoningStart, func(e Event, c Context) Result {
h(e.(ReasoningStartEvent), c)
return nil
})
},
onWarnings: func(h func(WarningsEvent, Context)) {
reg(Warnings, func(e Event, c Context) Result {
h(e.(WarningsEvent), c)
return nil
})
},
onSource: func(h func(SourceEvent, Context)) {
reg(Source, func(e Event, c Context) Result {
h(e.(SourceEvent), c)
return nil
})
},
onError: func(h func(ErrorEvent, Context)) {
reg(Error, func(e Event, c Context) Result {
h(e.(ErrorEvent), c)
return nil
})
},
onRetry: func(h func(RetryEvent, Context)) {
reg(Retry, func(e Event, c Context) Result {
h(e.(RetryEvent), c)
return nil
})
},
onPrepareStep: func(h func(PrepareStepEvent, Context) *PrepareStepResult) {
reg(PrepareStep, func(e Event, c Context) Result {
r := h(e.(PrepareStepEvent), c)
if r == nil {
return nil
}
return *r
})
},
onLLMUsage: func(h func(LLMUsageEvent, Context)) {
reg(LLMUsage, func(e Event, c Context) Result {
h(e.(LLMUsageEvent), c)
return nil
})
},
}
// Call Init — the extension registers its handlers, tools, commands.
-73
View File
@@ -72,30 +72,6 @@ func loadManifestFromPath(path string) (*Manifest, error) {
return &manifest, nil
}
// saveManifestToScope saves the manifest to the given scope.
func saveManifestToScope(manifest *Manifest, scope InstallScope) error {
path := manifestPathForScope(scope)
return saveManifestToPath(manifest, path)
}
// saveManifestToPath saves a manifest to a specific file path.
func saveManifestToPath(manifest *Manifest, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating manifest directory: %w", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encoding manifest: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// manifestPathForScope returns the manifest file path for a scope.
func manifestPathForScope(scope InstallScope) string {
if scope == ScopeProject {
@@ -113,55 +89,6 @@ func manifestPathForScope(scope InstallScope) string {
return filepath.Join(base, "kit", "git", "packages.json")
}
// GetGlobalManifest returns the global manifest.
func GetGlobalManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeGlobal)
}
// GetProjectManifest returns the project manifest.
func GetProjectManifest() (*Manifest, error) {
return loadManifestFromScope(ScopeProject)
}
// addEntryToManifest adds or replaces an entry in the manifest for a scope.
func addEntryToManifest(entry ManifestEntry, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
// Remove any existing entry with same identity
identity := entry.Identity()
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
filtered = append(filtered, entry)
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// removeEntryFromManifest removes an entry by identity from the manifest for a scope.
func removeEntryFromManifest(identity string, scope InstallScope) error {
manifest, err := loadManifestFromScope(scope)
if err != nil {
return err
}
filtered := make([]ManifestEntry, 0, len(manifest.Packages))
for _, p := range manifest.Packages {
if p.Identity() != identity {
filtered = append(filtered, p)
}
}
manifest.Packages = filtered
return saveManifestToScope(manifest, scope)
}
// FindInManifest finds an entry by identity in either global or project manifest.
// Returns the entry and its scope, or nil if not found.
func FindInManifest(identity string) (*ManifestEntry, InstallScope, error) {
+294 -5
View File
@@ -1,21 +1,96 @@
package extensions
import (
"bytes"
"encoding/json"
"fmt"
"log"
"maps"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"github.com/spf13/viper"
)
// ---------------------------------------------------------------------------
// reentrantMu — a per-extension mutex that allows the same goroutine to
// re-enter (e.g. handler → ctx.EmitCustomEvent → handler in same extension).
// Different goroutines are serialized, preventing concurrent state mutation.
// ---------------------------------------------------------------------------
type reentrantMu struct {
mu sync.Mutex
cond *sync.Cond
owner int64 // goroutine ID that holds the lock, or 0
depth int // re-entrancy depth
}
// initReentrantMu initializes the reentrant mutex in-place. Must be called
// after the struct is at its final memory location (not before copying).
func (r *reentrantMu) init() {
r.cond = sync.NewCond(&r.mu)
}
// lock acquires the mutex. If the calling goroutine already holds it, the
// call succeeds immediately (re-entrant). Every call to lock must be paired
// with a call to unlock.
func (r *reentrantMu) lock() {
gid := goroutineID()
r.mu.Lock()
if r.owner == gid {
// Re-entrant: same goroutine already holds the lock.
r.depth++
r.mu.Unlock()
return
}
// Wait for the current owner to release.
for r.owner != 0 {
r.cond.Wait() // releases mu, blocks, re-acquires mu on wake
}
r.owner = gid
r.depth = 1
r.mu.Unlock()
}
// unlock releases the mutex (or decrements re-entrancy depth).
func (r *reentrantMu) unlock() {
r.mu.Lock()
r.depth--
if r.depth == 0 {
r.owner = 0
r.cond.Signal()
}
r.mu.Unlock()
}
// goroutineID extracts the current goroutine's ID from runtime.Stack output.
// This is a well-known technique used by Go testing infrastructure.
func goroutineID() int64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
// Stack output starts with "goroutine NNN ["
s := buf[:n]
s = s[len("goroutine "):]
s = s[:bytes.IndexByte(s, ' ')]
id, _ := strconv.ParseInt(string(s), 10, 64)
return id
}
// Runner manages loaded extensions and dispatches events to their handlers
// sequentially. Handlers execute in extension
// load order; for cancellable events the first blocking result wins.
//
// Each extension has a dedicated reentrant mutex so that handlers for the
// same extension are serialized (preventing data races on shared package-level
// state), while handlers for different extensions may execute concurrently.
type Runner struct {
extensions []LoadedExtension
extMu []reentrantMu // per-extension reentrant mutex, indexed by extension position
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
statusEntries map[string]StatusBarEntry // keyed by status key
@@ -26,9 +101,24 @@ type Runner struct {
disabledTools map[string]bool // nil = all tools enabled
customEventSubs map[string][]func(string) // inter-extension event bus
optionOverrides map[string]string // runtime option overrides
configStore *viper.Viper // per-instance config store (nil = global)
state map[string]string // session-scoped extension state (last-write-wins)
stateMu sync.RWMutex // guards state independently of mu
saverMu sync.Mutex // serializes stateSaver invocations so atomic-rename writes don't interleave
stateSaver func() // optional persistence hook invoked after each state mutation
mu sync.RWMutex
}
// SetConfigStore sets the per-instance configuration store used by GetOption
// to resolve "options.<name>" config values. When unset (nil), GetOption falls
// back to the process-global viper store. Threading a per-Kit store keeps
// extension option resolution isolated between Kit instances.
func (r *Runner) SetConfigStore(v *viper.Viper) {
r.mu.Lock()
defer r.mu.Unlock()
r.configStore = v
}
// ShortcutEntry pairs a shortcut definition with its handler.
type ShortcutEntry struct {
Def ShortcutDef
@@ -52,7 +142,11 @@ type LoadedExtension struct {
// NewRunner creates a Runner from a set of loaded extensions.
func NewRunner(exts []LoadedExtension) *Runner {
return &Runner{extensions: exts}
mus := make([]reentrantMu, len(exts))
for i := range mus {
mus[i].init()
}
return &Runner{extensions: exts, extMu: mus}
}
// SetContext updates the runtime context (session ID, model, etc.) that is
@@ -177,6 +271,18 @@ func normalizeContext(ctx Context) Context {
if ctx.GetEntries == nil {
ctx.GetEntries = func(string) []ExtensionEntry { return nil }
}
if ctx.SetState == nil {
ctx.SetState = func(string, string) {}
}
if ctx.GetState == nil {
ctx.GetState = func(string) (string, bool) { return "", false }
}
if ctx.DeleteState == nil {
ctx.DeleteState = func(string) {}
}
if ctx.ListState == nil {
ctx.ListState = func() []string { return nil }
}
if ctx.GetOption == nil {
ctx.GetOption = func(string) string { return "" }
}
@@ -367,6 +473,11 @@ func (r *Runner) Emit(event Event) (Result, error) {
for i := range r.extensions {
ext := &r.extensions[i]
handlers := ext.Handlers[event.Type()]
if len(handlers) == 0 {
continue
}
r.extMu[i].lock()
for _, handler := range handlers {
result, err := safeCall(handler, event, ctx)
if err != nil {
@@ -379,6 +490,7 @@ func (r *Runner) Emit(event Event) (Result, error) {
// Check for blocking/short-circuit results.
if isBlocking(result) {
r.extMu[i].unlock()
return result, nil
}
@@ -386,6 +498,7 @@ func (r *Runner) Emit(event Event) (Result, error) {
// the caller is responsible for applying the modifications.
accumulated = result
}
r.extMu[i].unlock()
}
return accumulated, nil
}
@@ -651,6 +764,168 @@ func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
return nil
}
// ---------------------------------------------------------------------------
// Extension state store (session-scoped, last-write-wins)
// ---------------------------------------------------------------------------
// SetState records a key-value pair in the runner's session-scoped extension
// state store. The store is in-memory; callers wire SetStateSaver to persist
// changes to a sidecar file. Thread-safe.
//
// When a saver is installed, concurrent SetState/DeleteState invocations are
// serialized through saverMu so that overlapping snapshot-and-rename writes
// cannot interleave (which would otherwise race on the shared tmp file and
// risk persisting an older snapshot after a newer one).
func (r *Runner) SetState(key, value string) {
r.stateMu.Lock()
if r.state == nil {
r.state = make(map[string]string)
}
r.state[key] = value
saver := r.stateSaver
r.stateMu.Unlock()
r.runSaver(saver)
}
// GetState returns the value previously stored via SetState, plus a bool
// indicating whether the key was present. Thread-safe.
func (r *Runner) GetState(key string) (string, bool) {
r.stateMu.RLock()
defer r.stateMu.RUnlock()
v, ok := r.state[key]
return v, ok
}
// DeleteState removes a key from the state store. No-op if the key is
// missing. Thread-safe. Saver invocations are serialized via saverMu — see
// SetState for the rationale.
func (r *Runner) DeleteState(key string) {
r.stateMu.Lock()
_, existed := r.state[key]
if existed {
delete(r.state, key)
}
saver := r.stateSaver
r.stateMu.Unlock()
if !existed {
return
}
r.runSaver(saver)
}
// runSaver invokes the optional persistence callback under saverMu so
// concurrent SetState/DeleteState writers cannot race on the shared tmp
// file used by SaveStateToFile's atomic rename. The deferred Unlock
// guarantees saverMu is released even if the saver panics.
func (r *Runner) runSaver(saver func()) {
if saver == nil {
return
}
r.saverMu.Lock()
defer r.saverMu.Unlock()
saver()
}
// ListState returns all keys currently in the state store, in unspecified
// order. Thread-safe.
func (r *Runner) ListState() []string {
r.stateMu.RLock()
defer r.stateMu.RUnlock()
if len(r.state) == 0 {
return nil
}
keys := make([]string, 0, len(r.state))
for k := range r.state {
keys = append(keys, k)
}
return keys
}
// SetStateSaver installs an optional persistence hook invoked after each
// mutation to the state store (SetState / DeleteState / LoadStateFromFile).
// Pass nil to disable persistence. Thread-safe.
func (r *Runner) SetStateSaver(saver func()) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
r.stateSaver = saver
}
// SnapshotState returns a copy of the current state store as a
// fresh map. Useful for persisting to disk without holding the lock.
// Thread-safe.
func (r *Runner) SnapshotState() map[string]string {
r.stateMu.RLock()
defer r.stateMu.RUnlock()
if len(r.state) == 0 {
return nil
}
copyMap := make(map[string]string, len(r.state))
maps.Copy(copyMap, r.state)
return copyMap
}
// LoadStateFromFile reads a JSON map from path and replaces the in-memory
// state store with its contents. Missing or empty files are treated as
// "no prior state": the in-memory store is replaced with an empty map so
// callers can safely switch sessions without leaking keys from a prior
// session into a new one. Malformed JSON returns the parse error without
// touching the existing store. Thread-safe.
func (r *Runner) LoadStateFromFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
r.stateMu.Lock()
r.state = map[string]string{}
r.stateMu.Unlock()
return nil
}
return fmt.Errorf("reading extension state: %w", err)
}
if len(data) == 0 {
r.stateMu.Lock()
r.state = map[string]string{}
r.stateMu.Unlock()
return nil
}
var loaded map[string]string
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("parsing extension state: %w", err)
}
r.stateMu.Lock()
r.state = loaded
r.stateMu.Unlock()
return nil
}
// SaveStateToFile writes the current state store to path as JSON, creating
// parent directories as needed. An empty store writes an empty object so
// that consumers can distinguish "loaded but empty" from "never saved".
// Writes are atomic via a tmp-file-and-rename sequence. Thread-safe.
func (r *Runner) SaveStateToFile(path string) error {
snap := r.SnapshotState()
if snap == nil {
snap = map[string]string{}
}
data, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return fmt.Errorf("marshalling extension state: %w", err)
}
if dir := filepath.Dir(path); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating state directory: %w", err)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("writing extension state: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("renaming extension state: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Hot-reload
// ---------------------------------------------------------------------------
@@ -674,7 +949,9 @@ func (r *Runner) Reload(exts []LoadedExtension) {
r.uiVisibility = nil
r.disabledTools = nil
r.customEventSubs = nil
// optionOverrides are intentionally preserved.
// optionOverrides and state are intentionally preserved across reloads:
// they represent user/session intent (not extension code) and would be
// surprising to lose on a hot-reload.
}
// ---------------------------------------------------------------------------
@@ -712,11 +989,17 @@ func (r *Runner) EmitCustomEvent(name, data string) {
// Extension-registered handlers first (in load order).
for i := range r.extensions {
for _, h := range r.extensions[i].CustomEventHandlers[name] {
extHandlers := r.extensions[i].CustomEventHandlers[name]
if len(extHandlers) == 0 {
continue
}
r.extMu[i].lock()
for _, h := range extHandlers {
safeInvoke(h)
}
r.extMu[i].unlock()
}
// Then dynamic subscriptions.
// Then dynamic subscriptions (not extension-scoped, no per-ext lock).
for _, h := range dynamicHandlers {
safeInvoke(h)
}
@@ -783,7 +1066,13 @@ func (r *Runner) GetOption(name string) string {
// 3. Viper config: options.<name>
configKey := "options." + name
if v := viper.GetString(configKey); v != "" {
r.mu.RLock()
store := r.configStore
r.mu.RUnlock()
if store == nil {
store = viper.GetViper()
}
if v := store.GetString(configKey); v != "" {
return v
}
+140
View File
@@ -1,6 +1,7 @@
package extensions
import (
"sync"
"testing"
)
@@ -571,3 +572,142 @@ func TestRunner_ContextPrintNilSafe(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRunner_ConcurrentEmitSameExtension(t *testing.T) {
// Verify that concurrent Emit calls for the same extension are serialized
// and don't cause data races on shared handler state.
var counter int
ext := makeHandlerExt("shared-state.go", map[EventType][]HandlerFunc{
SubagentStart: {
func(e Event, c Context) Result {
// Read-modify-write: racy without serialization.
v := counter
counter = v + 1
return nil
},
},
SubagentChunk: {
func(e Event, c Context) Result {
v := counter
counter = v + 1
return nil
},
},
})
r := makeRunner(ext)
var wg sync.WaitGroup
const goroutines = 20
const iterations = 50
wg.Add(goroutines)
for range goroutines {
go func() {
defer wg.Done()
for range iterations {
_, _ = r.Emit(SubagentStartEvent{ToolCallID: "x"})
_, _ = r.Emit(SubagentChunkEvent{ToolCallID: "x"})
}
}()
}
wg.Wait()
if counter != goroutines*iterations*2 {
t.Errorf("expected counter=%d, got %d (race detected)", goroutines*iterations*2, counter)
}
}
func TestRunner_ConcurrentEmitDifferentExtensions(t *testing.T) {
// Two extensions with independent state should not block each other
// and should both run correctly under concurrent Emit calls.
var counter1, counter2 int
ext1 := makeHandlerExt("ext1.go", map[EventType][]HandlerFunc{
SubagentStart: {
func(e Event, c Context) Result {
v := counter1
counter1 = v + 1
return nil
},
},
})
ext2 := makeHandlerExt("ext2.go", map[EventType][]HandlerFunc{
SubagentStart: {
func(e Event, c Context) Result {
v := counter2
counter2 = v + 1
return nil
},
},
})
r := makeRunner(ext1, ext2)
var wg sync.WaitGroup
const goroutines = 20
const iterations = 50
wg.Add(goroutines)
for range goroutines {
go func() {
defer wg.Done()
for range iterations {
_, _ = r.Emit(SubagentStartEvent{ToolCallID: "x"})
}
}()
}
wg.Wait()
expected := goroutines * iterations
if counter1 != expected {
t.Errorf("ext1 counter: expected %d, got %d", expected, counter1)
}
if counter2 != expected {
t.Errorf("ext2 counter: expected %d, got %d", expected, counter2)
}
}
func TestRunner_ReentrantEmitCustomEvent(t *testing.T) {
// Verify that a handler can call EmitCustomEvent (which dispatches to
// the same extension's custom event handlers) without deadlocking.
var order []string
ext := LoadedExtension{
Path: "reentrant.go",
Handlers: map[EventType][]HandlerFunc{
SessionStart: {
func(e Event, c Context) Result {
order = append(order, "session_start")
// This triggers EmitCustomEvent for the same extension
// via a direct runner call (simulating ctx.EmitCustomEvent).
return nil
},
},
},
CustomEventHandlers: map[string][]func(string){
"test-event": {
func(data string) {
order = append(order, "custom:"+data)
},
},
},
}
r := makeRunner(ext)
// Wire up the handler to call EmitCustomEvent re-entrantly.
ext.Handlers[SessionStart] = []HandlerFunc{
func(e Event, c Context) Result {
order = append(order, "session_start")
r.EmitCustomEvent("test-event", "hello")
return nil
},
}
r.extensions[0] = ext
// Rebuild mutexes after modifying extensions slice.
r.extMu = make([]reentrantMu, len(r.extensions))
for i := range r.extMu {
r.extMu[i].init()
}
_, err := r.Emit(SessionStartEvent{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(order) != 2 || order[0] != "session_start" || order[1] != "custom:hello" {
t.Errorf("expected [session_start, custom:hello], got %v", order)
}
}
+262
View File
@@ -0,0 +1,262 @@
package extensions
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestRunner_State_BasicSetGetDelete(t *testing.T) {
r := NewRunner(nil)
if _, ok := r.GetState("missing"); ok {
t.Fatal("expected GetState to return ok=false for missing key")
}
r.SetState("a", "1")
r.SetState("b", "2")
r.SetState("a", "3") // last-write-wins
if v, ok := r.GetState("a"); !ok || v != "3" {
t.Errorf("expected GetState(a)=(3,true), got (%q,%v)", v, ok)
}
if v, ok := r.GetState("b"); !ok || v != "2" {
t.Errorf("expected GetState(b)=(2,true), got (%q,%v)", v, ok)
}
keys := r.ListState()
if len(keys) != 2 {
t.Errorf("expected 2 keys, got %d (%v)", len(keys), keys)
}
r.DeleteState("a")
if _, ok := r.GetState("a"); ok {
t.Error("expected key a to be gone after DeleteState")
}
if len(r.ListState()) != 1 {
t.Errorf("expected 1 key after delete, got %v", r.ListState())
}
// Deleting missing key is a no-op.
r.DeleteState("never-there")
}
func TestRunner_State_SaverFires(t *testing.T) {
r := NewRunner(nil)
var calls int
var mu sync.Mutex
r.SetStateSaver(func() {
mu.Lock()
calls++
mu.Unlock()
})
r.SetState("a", "1")
r.SetState("a", "2")
r.DeleteState("a")
r.DeleteState("a") // missing → no save
mu.Lock()
defer mu.Unlock()
if calls != 3 {
t.Errorf("expected saver to fire 3 times (2 sets + 1 delete), got %d", calls)
}
}
func TestRunner_State_SaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ext-state.json")
r1 := NewRunner(nil)
r1.SetState("k1", "v1")
r1.SetState("k2", `{"json":"value"}`)
if err := r1.SaveStateToFile(path); err != nil {
t.Fatalf("SaveStateToFile: %v", err)
}
// Verify file contains JSON map.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading saved file: %v", err)
}
var parsed map[string]string
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshalling: %v", err)
}
if parsed["k1"] != "v1" || parsed["k2"] != `{"json":"value"}` {
t.Errorf("unexpected file contents: %v", parsed)
}
r2 := NewRunner(nil)
if err := r2.LoadStateFromFile(path); err != nil {
t.Fatalf("LoadStateFromFile: %v", err)
}
if v, ok := r2.GetState("k1"); !ok || v != "v1" {
t.Errorf("expected k1=v1 after load, got (%q,%v)", v, ok)
}
if v, ok := r2.GetState("k2"); !ok || v != `{"json":"value"}` {
t.Errorf("expected k2 to round-trip, got %q", v)
}
}
func TestRunner_State_LoadMissingFileClearsState(t *testing.T) {
// LoadStateFromFile is documented to "replace the in-memory state store
// with its contents"; for a missing file that means clearing the store.
// This is what makes session-switching safe: a new session that has not
// yet written a sidecar must not inherit keys from a prior session.
r := NewRunner(nil)
r.SetState("a", "1")
if err := r.LoadStateFromFile(filepath.Join(t.TempDir(), "does-not-exist.json")); err != nil {
t.Errorf("expected nil error for missing file, got %v", err)
}
if _, ok := r.GetState("a"); ok {
t.Error("expected pre-existing state to be cleared when target file is missing")
}
if keys := r.ListState(); keys != nil {
t.Errorf("expected ListState() to be nil after clearing, got %v", keys)
}
}
func TestRunner_State_LoadEmptyFileClearsState(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.json")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
r := NewRunner(nil)
r.SetState("a", "1")
if err := r.LoadStateFromFile(path); err != nil {
t.Errorf("expected nil error for empty file, got %v", err)
}
if _, ok := r.GetState("a"); ok {
t.Error("expected pre-existing state to be cleared when target file is empty")
}
}
func TestRunner_State_LoadMalformedFileError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
r := NewRunner(nil)
if err := r.LoadStateFromFile(path); err == nil {
t.Error("expected error loading malformed JSON, got nil")
}
}
func TestRunner_State_PersistenceViaSaver(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ext-state.json")
r := NewRunner(nil)
r.SetStateSaver(func() {
_ = r.SaveStateToFile(path)
})
r.SetState("hello", "world")
// File should exist with the value already.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading saved file: %v", err)
}
var parsed map[string]string
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshalling: %v", err)
}
if parsed["hello"] != "world" {
t.Errorf("expected file to contain hello=world, got %v", parsed)
}
}
func TestRunner_State_ConcurrentSet(t *testing.T) {
r := NewRunner(nil)
var wg sync.WaitGroup
const goroutines = 16
const iterations = 100
wg.Add(goroutines)
for range goroutines {
go func() {
defer wg.Done()
for range iterations {
r.SetState("k", "v")
_, _ = r.GetState("k")
}
}()
}
wg.Wait()
if v, ok := r.GetState("k"); !ok || v != "v" {
t.Errorf("expected k=v after concurrent writes, got (%q,%v)", v, ok)
}
}
func TestRunner_State_ContextNoOpsWhenUnset(t *testing.T) {
// Verify normalizeContext installs safe no-ops for SetState/GetState/etc.
// when not provided by the caller.
ext := makeHandlerExt("state.go", map[EventType][]HandlerFunc{
SessionStart: {
func(e Event, c Context) Result {
// All four state functions should be non-nil and safe to call.
c.SetState("a", "b")
if v, ok := c.GetState("a"); ok || v != "" {
t.Errorf("no-op GetState should return (\"\", false); got (%q,%v)", v, ok)
}
c.DeleteState("a")
if keys := c.ListState(); keys != nil {
t.Errorf("no-op ListState should return nil; got %v", keys)
}
return nil
},
},
})
r := makeRunner(ext)
// SetContext with empty Context to exercise normalizeContext defaults.
r.SetContext(Context{})
_, err := r.Emit(SessionStartEvent{})
if err != nil {
t.Fatalf("emit: %v", err)
}
}
func TestRunner_State_SaverPanicReleasesSaverMu(t *testing.T) {
// If the saver callback panics (e.g. disk full mid-write), runSaver
// must still release saverMu so subsequent SetState/DeleteState calls
// can make progress. Without `defer Unlock()` the lock would be
// permanently held and the next write would deadlock.
r := NewRunner(nil)
var calls int
r.SetStateSaver(func() {
calls++
if calls == 1 {
panic("simulated disk-write failure")
}
})
// First call panics. Recover, then verify a follow-up call still works
// without blocking (proving saverMu was released).
func() {
defer func() {
if rec := recover(); rec == nil {
t.Fatal("expected panic from first saver invocation")
}
}()
r.SetState("a", "1")
}()
done := make(chan struct{})
go func() {
r.SetState("b", "2") // would deadlock if saverMu were still held
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("SetState after saver panic blocked — saverMu was not released")
}
if calls != 2 {
t.Errorf("expected saver to fire twice (panic + recovery write), got %d", calls)
}
}
-225
View File
@@ -2,22 +2,15 @@
package extensions
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
)
// ---------------------------------------------------------------------------
// Subagent types
// ---------------------------------------------------------------------------
// SubagentConfig configures a subagent spawn.
type SubagentConfig struct {
// Prompt is the task/instruction for the subagent (required).
@@ -157,221 +150,3 @@ func (h *SubagentHandle) Wait() SubagentResult {
func (h *SubagentHandle) Done() <-chan struct{} {
return h.done
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// subagentJSONOutput matches the JSON envelope produced by `kit --json`.
type subagentJSONOutput struct {
Response string `json:"response"`
StopReason string `json:"stop_reason,omitempty"`
SessionID string `json:"session_id,omitempty"`
Usage *struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage,omitempty"`
}
var subagentCounter atomic.Uint64
func generateSubagentID() string {
n := subagentCounter.Add(1)
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
}
func findKitBinary() string {
// Try the current process executable first.
if exe, err := os.Executable(); err == nil {
if _, err := os.Stat(exe); err == nil {
return exe
}
}
// Fall back to PATH lookup.
if p, err := exec.LookPath("kit"); err == nil {
return p
}
return "kit"
}
// ---------------------------------------------------------------------------
// SpawnSubagent implementation
// ---------------------------------------------------------------------------
// SpawnSubagent spawns a child Kit instance to perform a task.
//
// When config.Blocking is true, blocks until completion and returns the result
// directly (handle is nil). When false, returns immediately with a handle for
// monitoring/cancellation.
//
// The subagent runs with --json --no-session --no-extensions flags by default,
// ensuring isolation from the parent's extensions and session state.
func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
if cfg.Prompt == "" {
return nil, nil, fmt.Errorf("prompt is required")
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 5 * time.Minute
}
kitBinary := findKitBinary()
// Build subprocess arguments.
args := []string{
"--json",
"--no-extensions",
}
if cfg.NoSession {
args = append(args, "--no-session")
}
if cfg.Model != "" {
args = append(args, "--model", cfg.Model)
}
// Handle system prompt - write to temp file if provided.
var tmpFile *os.File
if cfg.SystemPrompt != "" {
var err error
tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt")
if err != nil {
return nil, nil, fmt.Errorf("create temp file: %w", err)
}
if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
return nil, nil, fmt.Errorf("write system prompt: %w", err)
}
_ = tmpFile.Close()
args = append(args, "--system-prompt", tmpFile.Name())
}
// Add the prompt as a positional argument.
args = append(args, cfg.Prompt)
// Create command with timeout context.
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, kitBinary, args...)
cmd.Env = os.Environ()
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("stderr pipe: %w", err)
}
handle := &SubagentHandle{
ID: generateSubagentID(),
done: make(chan struct{}),
}
// Start the subprocess.
start := time.Now()
if err := cmd.Start(); err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("start subprocess: %w", err)
}
handle.mu.Lock()
handle.proc = cmd.Process
handle.mu.Unlock()
// Run the subprocess monitoring in a goroutine.
go func() {
defer close(handle.done)
defer cancel()
if tmpFile != nil {
defer func() { _ = os.Remove(tmpFile.Name()) }()
}
var wg sync.WaitGroup
var stdoutBuf strings.Builder
// Read stderr (live output).
wg.Go(func() {
scanner := bufio.NewScanner(stderr)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
if cfg.OnOutput != nil && strings.TrimSpace(line) != "" {
cfg.OnOutput(line + "\n")
}
}
})
// Read stdout (JSON output).
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
stdoutBuf.WriteString(scanner.Text() + "\n")
}
wg.Wait()
waitErr := cmd.Wait()
elapsed := time.Since(start)
// Build result.
result := SubagentResult{Elapsed: elapsed}
if waitErr != nil {
result.Error = waitErr
if exitErr, ok := waitErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = 1
}
}
// Parse JSON output.
raw := strings.TrimSpace(stdoutBuf.String())
var parsed subagentJSONOutput
if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil {
result.Response = parsed.Response
result.SessionID = parsed.SessionID
if parsed.Usage != nil {
result.Usage = &SubagentUsage{
InputTokens: parsed.Usage.InputTokens,
OutputTokens: parsed.Usage.OutputTokens,
}
}
} else {
// Fallback: use raw stdout.
result.Response = raw
}
handle.mu.Lock()
handle.result = &result
handle.proc = nil
handle.mu.Unlock()
if cfg.OnComplete != nil {
cfg.OnComplete(result)
}
}()
if cfg.Blocking {
// Wait for completion and return result directly.
<-handle.done
handle.mu.Lock()
r := handle.result
handle.mu.Unlock()
return nil, r, nil
}
return handle, nil, nil
}
+15
View File
@@ -152,6 +152,9 @@ func Symbols() interp.Exports {
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
"ToolCallInputStartEvent": reflect.ValueOf((*ToolCallInputStartEvent)(nil)),
"ToolCallInputDeltaEvent": reflect.ValueOf((*ToolCallInputDeltaEvent)(nil)),
"ToolCallInputEndEvent": reflect.ValueOf((*ToolCallInputEndEvent)(nil)),
"ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)),
"ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)),
"ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)),
@@ -169,6 +172,18 @@ func Symbols() interp.Exports {
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
// Step lifecycle events
"StepStartEvent": reflect.ValueOf((*StepStartEvent)(nil)),
"StepFinishEvent": reflect.ValueOf((*StepFinishEvent)(nil)),
"ReasoningStartEvent": reflect.ValueOf((*ReasoningStartEvent)(nil)),
"WarningsEvent": reflect.ValueOf((*WarningsEvent)(nil)),
"SourceEvent": reflect.ValueOf((*SourceEvent)(nil)),
"ErrorEvent": reflect.ValueOf((*ErrorEvent)(nil)),
"RetryEvent": reflect.ValueOf((*RetryEvent)(nil)),
"PrepareStepEvent": reflect.ValueOf((*PrepareStepEvent)(nil)),
"PrepareStepResult": reflect.ValueOf((*PrepareStepResult)(nil)),
"LLMUsageEvent": reflect.ValueOf((*LLMUsageEvent)(nil)),
},
}
}
+6
View File
@@ -189,5 +189,11 @@ func NewTestAPI(ext *LoadedExtension) API {
return nil
})
},
onLLMUsage: func(h func(LLMUsageEvent, Context)) {
reg(LLMUsage, func(e Event, c Context) Result {
h(e.(LLMUsageEvent), c)
return nil
})
},
}
}
+38
View File
@@ -0,0 +1,38 @@
package extensions
// ToolKind constants classify what a tool does, enabling UIs to render
// appropriate visualizations (e.g. diff view for edit tools, command+output
// for execute tools) and file trackers to identify which results contain
// modifications.
//
// This is the single source of truth for tool-kind classification; the
// pkg/kit SDK re-exports these constants.
const (
ToolKindExecute = "execute" // Shell execution (bash)
ToolKindEdit = "edit" // File modification (edit, write)
ToolKindRead = "read" // File reading (read, ls)
ToolKindSearch = "search" // Content/file search (grep, find)
ToolKindSubagent = "agent" // Subagent spawning (subagent)
)
// coreToolKinds maps built-in tool names to their kind classification.
// MCP and extension tools without an entry default to ToolKindExecute.
var coreToolKinds = map[string]string{
"bash": ToolKindExecute,
"edit": ToolKindEdit,
"write": ToolKindEdit,
"read": ToolKindRead,
"ls": ToolKindRead,
"grep": ToolKindSearch,
"find": ToolKindSearch,
"subagent": ToolKindSubagent,
}
// ToolKindFor returns the ToolKind for a given tool name, defaulting to
// ToolKindExecute for unknown tools (including MCP tools).
func ToolKindFor(toolName string) string {
if kind, ok := coreToolKinds[toolName]; ok {
return kind
}
return ToolKindExecute
}
+24 -157
View File
@@ -1,143 +1,32 @@
package extensions
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/mark3labs/kit/internal/watcher"
)
// Watcher monitors extension directories for file changes and triggers
// a reload callback when .go files are created, modified, or removed.
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
type Watcher struct {
watcher *fsnotify.Watcher
onReload func()
debounce time.Duration
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
}
// Watcher monitors extension directories for .go file changes and triggers
// a reload callback when changes are detected. It is implemented in terms
// of the general-purpose internal/watcher.ContentWatcher.
//
// Type-aliasing here lets existing call sites (cmd/root.go and the
// watcher_test.go suite) keep using `extensions.NewWatcher` / `*Watcher`
// without knowing about the underlying implementation.
type Watcher = watcher.ContentWatcher
// NewWatcher creates a file watcher that monitors the given directories
// for .go file changes. When a change is detected (after debouncing),
// onReload is called. The watcher must be started with Start() and
// stopped with Close().
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating file watcher: %w", err)
}
for _, dir := range dirs {
// Watch the directory itself.
if err := fsw.Add(dir); err != nil {
log.Printf("DEBUG watcher: skipping directory: dir=%s err=%v", dir, err)
continue
}
// Also watch immediate subdirectories (for */main.go pattern).
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
subdir := filepath.Join(dir, entry.Name())
if err := fsw.Add(subdir); err != nil {
log.Printf("DEBUG watcher: skipping subdirectory: dir=%s err=%v", subdir, err)
}
}
}
}
return &Watcher{
watcher: fsw,
onReload: onReload,
debounce: 300 * time.Millisecond,
done: make(chan struct{}),
}, nil
}
// Start begins watching for file changes. It blocks until the context
// is cancelled or Close() is called. Typically called in a goroutine.
func (w *Watcher) Start(ctx context.Context) {
w.mu.Lock()
ctx, w.cancel = context.WithCancel(ctx)
w.mu.Unlock()
defer close(w.done)
var timer *time.Timer
var timerC <-chan time.Time
for {
select {
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Only care about .go files.
if !strings.HasSuffix(event.Name, ".go") {
continue
}
// React to write, create, remove, rename events.
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
continue
}
log.Printf("DEBUG watcher: file changed: file=%s op=%s", event.Name, event.Op)
// Debounce: reset timer on each event.
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(w.debounce)
timerC = timer.C
case <-timerC:
timerC = nil
timer = nil
log.Printf("DEBUG watcher: reloading extensions")
w.onReload()
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("WARN watcher: error: %v", err)
}
}
}
// Close stops the watcher and releases resources.
func (w *Watcher) Close() error {
w.mu.Lock()
cancel := w.cancel
w.mu.Unlock()
if cancel != nil {
cancel()
}
// Wait for the event loop to finish.
<-w.done
return w.watcher.Close()
return watcher.New(watcher.Options{
Dirs: dirs,
Extensions: []string{".go"},
OnReload: onReload,
Label: "extensions",
})
}
// WatchedDirs returns the directories to watch for extension changes.
@@ -146,47 +35,25 @@ func (w *Watcher) Close() error {
// point to directories are also included; explicit file paths cause
// their parent directory to be watched instead.
func WatchedDirs(extraPaths []string) []string {
var dirs []string
seen := make(map[string]bool)
add := func(dir string) {
abs, err := filepath.Abs(dir)
if err != nil {
return
}
if seen[abs] {
return
}
// Verify the directory exists.
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
return
}
seen[abs] = true
dirs = append(dirs, abs)
standard := []string{
globalExtensionsDir(),
filepath.Join(".kit", "extensions"),
}
// Global extensions dir.
add(globalExtensionsDir())
// Project-local extensions dir.
add(filepath.Join(".kit", "extensions"))
// Explicit paths that are directories.
// Filter explicit paths into directories (passed through) and files
// (parent dir watched) for CollectDirs to dedupe.
var extras []string
for _, p := range extraPaths {
info, err := os.Stat(p)
if err != nil {
continue
}
if info.IsDir() {
add(p)
extras = append(extras, p)
} else {
// For explicit files, watch the parent directory.
add(filepath.Dir(p))
extras = append(extras, filepath.Dir(p))
}
}
return dirs
return watcher.CollectDirs(standard, extras)
}
+4 -27
View File
@@ -40,27 +40,6 @@ func ExtensionToolsAsLLMTools(defs []ToolDef, runner *Runner) []fantasy.AgentToo
return tools
}
// coreToolKinds maps built-in tool names to their kind classification.
var coreToolKinds = map[string]string{
"bash": "execute",
"edit": "edit",
"write": "edit",
"read": "read",
"ls": "read",
"grep": "search",
"find": "search",
"subagent": "agent",
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
// "execute" for unknown tools (including MCP tools).
func toolKindFor(toolName string) string {
if kind, ok := coreToolKinds[toolName]; ok {
return kind
}
return "execute"
}
// parseToolArgsJSON attempts to parse JSON-encoded tool args into a map.
// Returns nil on failure (non-fatal convenience parsing).
func parseToolArgsJSON(input string) map[string]any {
@@ -90,11 +69,10 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
// 0. Check if tool is disabled via SetActiveTools.
if w.runner.IsToolDisabled(toolName) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
fmt.Errorf("tool %q disabled by extension", toolName)
fmt.Sprintf("Error: tool %q is currently disabled", toolName)), nil
}
kind := toolKindFor(toolName)
kind := ToolKindFor(toolName)
// 1. Emit ToolCall — extensions can block execution.
if w.runner.HasHandlers(ToolCall) {
@@ -111,8 +89,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
if reason == "" {
reason = "blocked by extension"
}
return fantasy.NewTextErrorResponse(fmt.Sprintf("Error: %s", reason)),
fmt.Errorf("tool blocked by extension: %s", reason)
return fantasy.NewTextErrorResponse(fmt.Sprintf("Error: %s", reason)), nil
}
}
@@ -238,7 +215,7 @@ func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy
}
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), err
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return fantasy.NewTextResponse(result), nil
}
+4 -4
View File
@@ -142,8 +142,8 @@ func TestWrappedTool_BlockExecution(t *testing.T) {
if toolRan {
t.Error("tool should not have run after block")
}
if err == nil {
t.Error("expected error from blocked tool")
if err != nil {
t.Error("expected nil error for blocked tool (error is conveyed via IsError response)")
}
if resp.IsError != true {
t.Error("expected IsError=true from blocked response")
@@ -234,8 +234,8 @@ func TestExtensionTool_Error(t *testing.T) {
tools := ExtensionToolsAsLLMTools(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
if err == nil {
t.Error("expected error")
if err != nil {
t.Error("expected nil error (error is conveyed via IsError response)")
}
if !resp.IsError {
t.Error("expected IsError=true")
+69 -42
View File
@@ -46,9 +46,9 @@ type AgentSetupOptions struct {
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
// ProviderConfig, when non-nil, is used directly instead of calling
// BuildProviderConfig(). Callers that already hold viperInitMu can
// pre-build this and release the lock before calling SetupAgent, so the
// slow agent/MCP initialisation runs concurrently with other New() calls.
// BuildProviderConfig(). Callers (e.g. Kit.New) pre-build this from their
// per-instance config store and pass it here, so the slow agent/MCP
// initialisation can run without further config reads.
ProviderConfig *models.ProviderConfig
// Debug enables debug logging. When zero-value, viper is consulted.
// Only meaningful when ProviderConfig is also set.
@@ -72,6 +72,14 @@ type AgentSetupOptions struct {
// OnMCPServerLoaded, if non-nil, is called when each MCP server finishes
// loading (successfully or with error). Called from the background goroutine.
OnMCPServerLoaded func(serverName string, toolCount int, err error)
// MCPTaskConfig configures task-augmented tools/call execution. The
// zero value preserves historical synchronous-only behaviour.
MCPTaskConfig tools.MCPTaskConfig
// Viper is the per-instance configuration store. When set, it is used for
// any fallback config reads (debug, no-extensions, max-steps, stream,
// extension paths) and is attached to the extension runner. When nil, the
// process-global viper store is used.
Viper *viper.Viper
}
// AgentSetupResult bundles the created agent and any debug logger so the caller
@@ -84,57 +92,62 @@ type AgentSetupResult struct {
ExtRunner *extensions.Runner
}
// BuildProviderConfig creates a *models.ProviderConfig from the current viper
// state. All entry points (root, script, SDK) converge through this function.
// BuildProviderConfig creates a *models.ProviderConfig from the supplied viper
// store (or the process-global store when v is nil). All entry points (root,
// script, SDK) converge through this function.
//
// Generation parameter pointers (Temperature, TopP, etc.) are only set when
// the user has explicitly configured them via CLI flag, environment variable,
// or global config file. This allows per-model defaults from modelSettings
// and customModels to fill in unset parameters downstream.
func BuildProviderConfig() (*models.ProviderConfig, string, error) {
systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt"))
func BuildProviderConfig(v *viper.Viper) (*models.ProviderConfig, string, error) {
if v == nil {
v = viper.GetViper()
}
systemPrompt, err := config.LoadSystemPrompt(v.GetString("system-prompt"))
if err != nil {
return nil, "", fmt.Errorf("failed to load system prompt: %w", err)
}
numGPU := int32(viper.GetInt("num-gpu-layers"))
mainGPU := int32(viper.GetInt("main-gpu"))
numGPU := int32(v.GetInt("num-gpu-layers"))
mainGPU := int32(v.GetInt("main-gpu"))
cfg := &models.ProviderConfig{
ModelString: viper.GetString("model"),
ModelString: v.GetString("model"),
SystemPrompt: systemPrompt,
ProviderAPIKey: viper.GetString("provider-api-key"),
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
StopSequences: viper.GetStringSlice("stop-sequences"),
ProviderAPIKey: v.GetString("provider-api-key"),
ProviderURL: v.GetString("provider-url"),
MaxTokens: v.GetInt("max-tokens"),
StopSequences: v.GetStringSlice("stop-sequences"),
NumGPU: &numGPU,
MainGPU: &mainGPU,
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
TLSSkipVerify: v.GetBool("tls-skip-verify"),
ThinkingLevel: models.ParseThinkingLevel(v.GetString("thinking-level")),
ConfigStore: v,
}
// Only set generation parameter pointers when the user has explicitly
// provided a value. This leaves nil pointers for unset params, allowing
// per-model defaults (modelSettings / customModels params) to apply.
if viper.IsSet("temperature") {
v := float32(viper.GetFloat64("temperature"))
cfg.Temperature = &v
if v.IsSet("temperature") {
val := float32(v.GetFloat64("temperature"))
cfg.Temperature = &val
}
if viper.IsSet("top-p") {
v := float32(viper.GetFloat64("top-p"))
cfg.TopP = &v
if v.IsSet("top-p") {
val := float32(v.GetFloat64("top-p"))
cfg.TopP = &val
}
if viper.IsSet("top-k") {
v := int32(viper.GetInt("top-k"))
cfg.TopK = &v
if v.IsSet("top-k") {
val := int32(v.GetInt("top-k"))
cfg.TopK = &val
}
if viper.IsSet("frequency-penalty") {
v := float32(viper.GetFloat64("frequency-penalty"))
cfg.FrequencyPenalty = &v
if v.IsSet("frequency-penalty") {
val := float32(v.GetFloat64("frequency-penalty"))
cfg.FrequencyPenalty = &val
}
if viper.IsSet("presence-penalty") {
v := float32(viper.GetFloat64("presence-penalty"))
cfg.PresencePenalty = &v
if v.IsSet("presence-penalty") {
val := float32(v.GetFloat64("presence-penalty"))
cfg.PresencePenalty = &val
}
return cfg, systemPrompt, nil
@@ -146,14 +159,21 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
var modelConfig *models.ProviderConfig
var systemPrompt string
// Resolve the config store: prefer the per-instance store, falling back to
// the process-global store.
v := opts.Viper
if v == nil {
v = viper.GetViper()
}
if opts.ProviderConfig != nil {
// Pre-built config supplied by caller (e.g. Kit.New after releasing
// viperInitMu). Use it directly — no viper reads needed here.
// Pre-built config supplied by caller (e.g. Kit.New after building the
// per-instance store). Use it directly — no viper reads needed here.
modelConfig = opts.ProviderConfig
systemPrompt = modelConfig.SystemPrompt
} else {
var err error
modelConfig, systemPrompt, err = BuildProviderConfig()
modelConfig, systemPrompt, err = BuildProviderConfig(v)
if err != nil {
return nil, err
}
@@ -161,13 +181,13 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
// Resolve debug / no-extensions / max-steps / streaming: prefer explicit
// fields (set when ProviderConfig was pre-built) over viper fallback.
debugEnabled := opts.Debug || viper.GetBool("debug")
noExtensions := opts.NoExtensions || viper.GetBool("no-extensions")
debugEnabled := opts.Debug || v.GetBool("debug")
noExtensions := opts.NoExtensions || v.GetBool("no-extensions")
maxSteps := opts.MaxSteps
if maxSteps == 0 {
maxSteps = viper.GetInt("max-steps")
maxSteps = v.GetInt("max-steps")
}
streamingEnabled := opts.StreamingEnabled || viper.GetBool("stream")
streamingEnabled := opts.StreamingEnabled || v.GetBool("stream")
// Create the appropriate debug logger.
var debugLogger tools.DebugLogger
@@ -186,7 +206,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
var extCreationOpts extensionCreationOpts
if !noExtensions {
var extErr error
extRunner, extCreationOpts, extErr = loadExtensions()
extRunner, extCreationOpts, extErr = loadExtensions(v)
if extErr != nil {
fmt.Printf("Warning: Failed to load extensions: %v\n", extErr)
}
@@ -229,6 +249,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
ToolWrapper: toolWrapper,
ExtraTools: extraTools,
OnMCPServerLoaded: opts.OnMCPServerLoaded,
MCPTaskConfig: opts.MCPTaskConfig,
})
if err != nil {
return nil, fmt.Errorf("failed to create agent: %w", err)
@@ -249,9 +270,14 @@ type extensionCreationOpts struct {
}
// loadExtensions discovers and loads Yaegi extensions, builds the runner,
// and returns the tool wrapper/extra tools.
func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
extraPaths := viper.GetStringSlice("extension")
// and returns the tool wrapper/extra tools. The supplied store is used to
// resolve the "extension" config key and is attached to the runner so
// extension option lookups stay isolated to this Kit instance.
func loadExtensions(v *viper.Viper) (*extensions.Runner, extensionCreationOpts, error) {
if v == nil {
v = viper.GetViper()
}
extraPaths := v.GetStringSlice("extension")
loaded, err := extensions.LoadExtensions(extraPaths)
if err != nil {
return nil, extensionCreationOpts{}, err
@@ -262,6 +288,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
}
runner := extensions.NewRunner(loaded)
runner.SetConfigStore(v)
wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool {
return extensions.WrapToolsWithExtensions(tools, runner)
+282
View File
@@ -0,0 +1,282 @@
package models
import (
"context"
"io"
"net/http"
"reflect"
"strings"
"testing"
)
// TestNpmToWireProtocol documents the wire protocols that the auto-router
// understands. Provider-specific bundles that need bespoke auth or URL
// templating (azure, bedrock, openrouter, google-vertex*, @ai-sdk/gateway)
// are intentionally absent — they have native top-level cases in
// CreateProvider and never reach the auto-router.
func TestNpmToWireProtocol(t *testing.T) {
want := map[string]wireProtocol{
"@ai-sdk/openai": wireOpenAI,
"@ai-sdk/openai-compatible": wireOpenAI,
"@ai-sdk/anthropic": wireAnthropic,
"@ai-sdk/google": wireGoogle,
// Thin OpenAI-compatible wrappers — routed via openaicompat using
// the SDK's hard-coded default base URL (sdkDefaultBaseURL).
"@ai-sdk/groq": wireOpenAI,
"@ai-sdk/cerebras": wireOpenAI,
"@ai-sdk/perplexity": wireOpenAI,
"@ai-sdk/togetherai": wireOpenAI,
"@ai-sdk/xai": wireOpenAI,
"@ai-sdk/deepinfra": wireOpenAI,
"@ai-sdk/mistral": wireOpenAI,
"@ai-sdk/cohere": wireOpenAI,
"@ai-sdk/vercel": wireOpenAI,
"@aihubmix/ai-sdk-provider": wireOpenAI,
"venice-ai-sdk-provider": wireOpenAI,
"merge-gateway-ai-sdk-provider": wireOpenAI,
}
for npm, wire := range want {
if got := npmToWireProtocol[npm]; got != wire {
t.Errorf("npmToWireProtocol[%q] = %d, want %d", npm, got, wire)
}
}
// Bundle packages must NOT be in the table — they need bespoke auth or
// URL templating that the auto-router cannot satisfy.
for _, npm := range []string{
"@ai-sdk/google-vertex",
"@ai-sdk/google-vertex/anthropic",
"@ai-sdk/amazon-bedrock",
"@ai-sdk/azure",
"@openrouter/ai-sdk-provider",
"@ai-sdk/gateway",
} {
if _, ok := npmToWireProtocol[npm]; ok {
t.Errorf("npmToWireProtocol unexpectedly contains bundle package %q", npm)
}
}
}
// newTestRegistry builds a registry containing a single proxy-style provider
// ("testproxy") with the given default npm, plus one model that carries the
// given per-model npm override.
func newTestRegistry(api, defaultNPM, modelID, modelNPMOverride string) *ModelsRegistry {
return &ModelsRegistry{
providers: map[string]ProviderInfo{
"testproxy": {
ID: "testproxy",
Name: "Test Proxy",
Env: []string{"TESTPROXY_API_KEY"},
NPM: defaultNPM,
API: api,
Models: map[string]ModelInfo{
modelID: {
ID: modelID,
Name: modelID,
ProviderNPM: modelNPMOverride,
},
},
},
},
}
}
// TestAutoRouteProvider_WireRouting verifies that autoRouteProvider routes each
// npm package to the correct fantasy provider implementation. This is the core
// regression test for issue #41: previously any npm that resolved to a
// non-openai/anthropic/openaicompat LLM provider (notably @ai-sdk/google) hit a
// dead `default` branch and failed with "has no LLM provider mapping".
func TestAutoRouteProvider_WireRouting(t *testing.T) {
tests := []struct {
name string
modelID string
defaultNPM string
overrideNPM string
// wantType is the concrete fantasy LanguageModel type the model should
// be routed to, identified by reflect type string.
wantType string
}{
{
name: "openai-compatible default",
modelID: "test-model",
defaultNPM: "@ai-sdk/openai-compatible",
wantType: "openai.languageModel",
},
{
name: "anthropic override",
modelID: "test-model",
defaultNPM: "@ai-sdk/openai-compatible",
overrideNPM: "@ai-sdk/anthropic",
wantType: "anthropic.languageModel",
},
{
name: "openai (responses) override",
modelID: "gpt-4o",
defaultNPM: "@ai-sdk/openai-compatible",
overrideNPM: "@ai-sdk/openai",
wantType: "openai.responsesLanguageModel",
},
{
// The bug: opencode's gemini-* models override the default
// openai-compatible npm with @ai-sdk/google.
name: "google override (issue #41)",
modelID: "gemini-3.5-flash",
defaultNPM: "@ai-sdk/openai-compatible",
overrideNPM: "@ai-sdk/google",
wantType: "*google.languageModel",
},
{
// Unknown npm but provider has an API URL → openai-compatible fallback.
name: "unknown npm with API URL falls back to openai-compat",
modelID: "test-model",
defaultNPM: "@ai-sdk/some-future-thing",
wantType: "openai.languageModel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry("https://proxy.example/v1", tt.defaultNPM, tt.modelID, tt.overrideNPM)
config := &ProviderConfig{ProviderAPIKey: "test-key"}
result, err := autoRouteProvider(context.Background(), config, "testproxy", tt.modelID, reg)
if err != nil {
t.Fatalf("autoRouteProvider returned error: %v", err)
}
if result == nil || result.Model == nil {
t.Fatalf("autoRouteProvider returned nil model")
}
gotType := reflect.TypeOf(result.Model).String()
if gotType != tt.wantType {
t.Errorf("routed to %s, want %s", gotType, tt.wantType)
}
})
}
}
// TestAutoRouteProvider_UnknownNpmNoAPI verifies the improved error message for
// a provider whose npm has no known wire protocol and that has no API URL to
// fall back on.
func TestAutoRouteProvider_UnknownNpmNoAPI(t *testing.T) {
reg := newTestRegistry("", "@ai-sdk/unmapped", "test-model", "")
config := &ProviderConfig{ProviderAPIKey: "test-key"}
_, err := autoRouteProvider(context.Background(), config, "testproxy", "test-model", reg)
if err == nil {
t.Fatal("expected error for unknown npm with no API URL, got nil")
}
if !strings.Contains(err.Error(), "cannot auto-route provider testproxy") {
t.Errorf("unexpected error message: %v", err)
}
if !strings.Contains(err.Error(), "--provider-url") {
t.Errorf("error should suggest --provider-url, got: %v", err)
}
}
// TestAutoRouteProvider_UnknownProvider verifies the not-in-database error.
func TestAutoRouteProvider_UnknownProvider(t *testing.T) {
reg := newTestRegistry("https://proxy.example/v1", "@ai-sdk/openai-compatible", "test-model", "")
config := &ProviderConfig{ProviderAPIKey: "test-key"}
_, err := autoRouteProvider(context.Background(), config, "does-not-exist", "test-model", reg)
if err == nil {
t.Fatal("expected error for unknown provider, got nil")
}
if !strings.Contains(err.Error(), "not found in model database") {
t.Errorf("unexpected error message: %v", err)
}
}
// TestIsProviderLLMSupported_Google verifies that a provider whose npm is
// @ai-sdk/google is reported as supported (it now maps to a wire protocol).
func TestIsProviderLLMSupported_Google(t *testing.T) {
info := &ProviderInfo{ID: "testproxy", NPM: "@ai-sdk/google"}
if !isProviderLLMSupported("testproxy", info) {
t.Error("expected @ai-sdk/google provider to be LLM-supported")
}
}
// TestVersionedBasePath verifies detection of proxy base URLs that already
// carry an API version segment (which collides with the genai SDK's injected
// version).
func TestVersionedBasePath(t *testing.T) {
tests := []struct {
rawURL string
want string
}{
{"https://opencode.ai/zen/v1", "/zen/v1"},
{"https://opencode.ai/zen/v1/", "/zen/v1"},
{"https://example.com/api/v1beta", "/api/v1beta"},
{"https://example.com/api/v2alpha", "/api/v2alpha"},
{"https://generativelanguage.googleapis.com", ""},
{"https://proxy.example/openai", ""},
{"", ""},
}
for _, tt := range tests {
if got := versionedBasePath(tt.rawURL); got != tt.want {
t.Errorf("versionedBasePath(%q) = %q, want %q", tt.rawURL, got, tt.want)
}
}
}
// recordingRoundTripper captures the path of the request it receives.
type recordingRoundTripper struct{ gotPath string }
func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
r.gotPath = req.URL.Path
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("{}")),
Header: make(http.Header),
}, nil
}
// TestGeminiProxyTransport_StripsInjectedVersion verifies that the transport
// collapses the genai-injected "/v1beta" segment that follows a proxy base
// URL which already carries its own version segment. This is the second-order
// fix that makes opencode/gemini-* actually reach the proxy (issue #41).
func TestGeminiProxyTransport_StripsInjectedVersion(t *testing.T) {
tests := []struct {
name string
basePath string
reqPath string
wantPath string
}{
{
name: "strips doubled v1beta after /zen/v1",
basePath: "/zen/v1",
reqPath: "/zen/v1/v1beta/models/gemini-3.5-flash:generateContent",
wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent",
},
{
name: "strips doubled v1beta1 after /zen/v1",
basePath: "/zen/v1",
reqPath: "/zen/v1/v1beta1/models/gemini-3.5-flash:generateContent",
wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent",
},
{
name: "leaves non-matching path untouched",
basePath: "/zen/v1",
reqPath: "/other/v1beta/models/x:generateContent",
wantPath: "/other/v1beta/models/x:generateContent",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := &recordingRoundTripper{}
tr := &geminiProxyTransport{base: rec, basePath: tt.basePath}
req, err := http.NewRequest(http.MethodPost, "https://host"+tt.reqPath, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
if _, err := tr.RoundTrip(req); err != nil {
t.Fatalf("RoundTrip: %v", err)
}
if rec.gotPath != tt.wantPath {
t.Errorf("forwarded path = %q, want %q", rec.gotPath, tt.wantPath)
}
})
}
}
-17
View File
@@ -3,7 +3,6 @@ package models
import (
"crypto/sha256"
"encoding/hex"
"maps"
"os"
"charm.land/fantasy"
@@ -69,19 +68,3 @@ func generateCacheKey(systemPrompt, modelID string) string {
// Prefix with "kit-" to identify KIT-generated cache keys
return "kit-" + hex.EncodeToString(h.Sum(nil))[:24]
}
// mergeProviderOptions merges multiple ProviderOptions maps.
// Later maps take precedence over earlier ones.
func mergeProviderOptions(opts ...fantasy.ProviderOptions) fantasy.ProviderOptions {
result := make(fantasy.ProviderOptions)
for _, opt := range opts {
maps.Copy(result, opt)
}
if len(result) == 0 {
return nil
}
return result
}
-56
View File
@@ -3,8 +3,6 @@ package models
import (
"os"
"testing"
"charm.land/fantasy"
)
func TestModelInfo_SupportsCaching(t *testing.T) {
@@ -192,57 +190,3 @@ func TestCachingPriorityOverThinking(t *testing.T) {
t.Errorf("OpenAI caching should work when thinking is OFF")
}
}
func TestMergeProviderOptions(t *testing.T) {
opts1 := fantasy.ProviderOptions{
"provider1": &testProviderData{value: "value1"},
}
opts2 := fantasy.ProviderOptions{
"provider2": &testProviderData{value: "value2"},
}
merged := mergeProviderOptions(opts1, opts2)
if len(merged) != 2 {
t.Errorf("mergeProviderOptions should combine options from multiple maps, got %d items", len(merged))
}
if _, ok := merged["provider1"]; !ok {
t.Errorf("merged options should contain 'provider1' key")
}
if _, ok := merged["provider2"]; !ok {
t.Errorf("merged options should contain 'provider2' key")
}
// Later options should override earlier ones
opts3 := fantasy.ProviderOptions{
"provider1": &testProviderData{value: "overridden"},
}
merged2 := mergeProviderOptions(opts1, opts3)
if data, ok := merged2["provider1"].(*testProviderData); ok {
if data.value != "overridden" {
t.Errorf("later options should override earlier ones, got %q", data.value)
}
}
if mergeProviderOptions() != nil {
t.Errorf("mergeProviderOptions with no args should return nil")
}
}
// testProviderData is a simple implementation of ProviderOptionsData for testing
type testProviderData struct {
value string
}
func (t *testProviderData) Options() {}
func (t *testProviderData) MarshalJSON() ([]byte, error) {
return []byte(`"` + t.value + `"`), nil
}
func (t *testProviderData) UnmarshalJSON(data []byte) error {
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package models
import (
"net/http"
"testing"
"time"
)
func TestCopilotProviderAliasUsesCatalog(t *testing.T) {
registry := NewModelsRegistry()
models, err := registry.GetModelsForProvider("copilot")
if err != nil {
t.Fatalf("GetModelsForProvider(copilot) failed: %v", err)
}
if len(models) == 0 {
t.Fatal("expected copilot alias to return github-copilot catalog models")
}
if registry.LookupModel("copilot", "gpt-5.5") == nil {
t.Fatal("expected copilot/gpt-5.5 to resolve through github-copilot catalog")
}
if registry.GetProviderInfo("copilot") == nil {
t.Fatal("expected copilot alias to return github-copilot provider info")
}
}
func TestCopilotRejectsNonGPTModels(t *testing.T) {
_, err := CreateProvider(t.Context(), &ProviderConfig{ModelString: "copilot/claude-sonnet-4.6"})
if err == nil {
t.Fatal("expected non-GPT Copilot model to be rejected")
}
}
func TestCopilotHTTPClientCachesToken(t *testing.T) {
client := createCopilotHTTPClient("cached-token", time.Now().Add(time.Hour).Unix(), false)
transport, ok := client.Transport.(*copilotTransport)
if !ok {
t.Fatal("expected *copilotTransport")
}
token := transport.cachedToken(t.Context())
if token != "cached-token" {
t.Fatalf("expected cached token, got %q", token)
}
}
func TestCopilotTransportHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
transport := &copilotTransport{
base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("Authorization") != "Bearer cached-token" {
t.Fatalf("unexpected Authorization header: %q", req.Header.Get("Authorization"))
}
if req.Header.Get("Copilot-Integration-Id") != copilotIntegrationID {
t.Fatalf("unexpected Copilot-Integration-Id header: %q", req.Header.Get("Copilot-Integration-Id"))
}
if req.Header.Get("Editor-Version") != copilotEditorVersion {
t.Fatalf("unexpected Editor-Version header: %q", req.Header.Get("Editor-Version"))
}
if req.Header.Get("User-Agent") != copilotUserAgent {
t.Fatalf("unexpected User-Agent header: %q", req.Header.Get("User-Agent"))
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
}),
token: "cached-token",
expiresAt: time.Now().Add(time.Hour).Unix(),
}
resp, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip failed: %v", err)
}
_ = resp.Body.Close()
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+46 -20
View File
@@ -10,14 +10,24 @@ import (
// loadCustomModelsFromConfig loads custom model definitions from the config file
// and returns them as a map of model ID -> ModelInfo. Returns nil if no custom
// models are configured.
// models are configured. Reads from the process-global viper store (the model
// registry is a process-global singleton).
func loadCustomModelsFromConfig() map[string]ModelInfo {
if !viper.IsSet("customModels") {
return loadCustomModelsFrom(viper.GetViper())
}
// loadCustomModelsFrom loads custom model definitions from the supplied store.
// When v is nil the process-global store is used.
func loadCustomModelsFrom(v *viper.Viper) map[string]ModelInfo {
if v == nil {
v = viper.GetViper()
}
if !v.IsSet("customModels") {
return nil
}
var customModels map[string]CustomModelConfig
if err := viper.UnmarshalKey("customModels", &customModels); err != nil {
if err := v.UnmarshalKey("customModels", &customModels); err != nil {
log.Printf("Warning: Failed to parse customModels: %v", err)
return nil
}
@@ -59,16 +69,20 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
return info
}
// LoadModelSettingsFromConfig loads per-model generation parameter overrides
// from the config file. Keys are "provider/model" strings. Returns nil if
// no model settings are configured.
func LoadModelSettingsFromConfig() map[string]*GenerationParams {
if !viper.IsSet("modelSettings") {
// LoadModelSettingsFrom loads per-model generation parameter overrides from the
// supplied per-instance store. When v is nil the process-global store is used.
// Keys are "provider/model" strings. Returns nil if no model settings are
// configured.
func LoadModelSettingsFrom(v *viper.Viper) map[string]*GenerationParams {
if v == nil {
v = viper.GetViper()
}
if !v.IsSet("modelSettings") {
return nil
}
var settings map[string]GenerationParamsConfig
if err := viper.UnmarshalKey("modelSettings", &settings); err != nil {
if err := v.UnmarshalKey("modelSettings", &settings); err != nil {
log.Printf("Warning: Failed to parse modelSettings: %v", err)
return nil
}
@@ -148,12 +162,17 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
return
}
// Resolve the config store: prefer the per-instance store carried on the
// ProviderConfig (set by BuildProviderConfig / Kit.New), falling back to
// the process-global store for callers that don't thread one through.
store := config.ConfigStore
// Collect model-level params: modelSettings override > custom model params.
// modelSettings takes priority because it's the more specific/intentional config.
var params *GenerationParams
// First check modelSettings from config.
if settings := LoadModelSettingsFromConfig(); settings != nil {
if settings := LoadModelSettingsFrom(store); settings != nil {
modelKey := provider + "/" + modelName
if p, ok := settings[modelKey]; ok {
params = p
@@ -173,28 +192,28 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
// We check viper.IsSet() which returns true only when the key was
// set via CLI flag, environment variable, or config file global section.
if params.MaxTokens != nil && !isExplicitlySet("max-tokens") {
if params.MaxTokens != nil && !isExplicitlySet(store, "max-tokens") {
config.MaxTokens = *params.MaxTokens
}
if params.Temperature != nil && !isExplicitlySet("temperature") {
if params.Temperature != nil && !isExplicitlySet(store, "temperature") {
config.Temperature = params.Temperature
}
if params.TopP != nil && !isExplicitlySet("top-p") {
if params.TopP != nil && !isExplicitlySet(store, "top-p") {
config.TopP = params.TopP
}
if params.TopK != nil && !isExplicitlySet("top-k") {
if params.TopK != nil && !isExplicitlySet(store, "top-k") {
config.TopK = params.TopK
}
if params.FrequencyPenalty != nil && !isExplicitlySet("frequency-penalty") {
if params.FrequencyPenalty != nil && !isExplicitlySet(store, "frequency-penalty") {
config.FrequencyPenalty = params.FrequencyPenalty
}
if params.PresencePenalty != nil && !isExplicitlySet("presence-penalty") {
if params.PresencePenalty != nil && !isExplicitlySet(store, "presence-penalty") {
config.PresencePenalty = params.PresencePenalty
}
if len(params.StopSequences) > 0 && !isExplicitlySet("stop-sequences") {
if len(params.StopSequences) > 0 && !isExplicitlySet(store, "stop-sequences") {
config.StopSequences = params.StopSequences
}
if params.ThinkingLevel != "" && !isExplicitlySet("thinking-level") {
if params.ThinkingLevel != "" && !isExplicitlySet(store, "thinking-level") {
config.ThinkingLevel = params.ThinkingLevel
}
if params.SystemPrompt != "" && config.SystemPrompt == "" {
@@ -228,7 +247,14 @@ func LoadSystemPromptValue(input string) string {
// isExplicitlySet returns true when the user has explicitly set a config key
// via CLI flag, environment variable, or the global section of the config file.
// Model-level defaults should not override explicitly set values.
func isExplicitlySet(key string) bool {
//
// The check runs against the supplied per-instance store when non-nil,
// otherwise the process-global store. This keeps the "explicit vs unset"
// precedence contract per-Kit-instance once a store is threaded through.
func isExplicitlySet(v *viper.Viper, key string) bool {
if v == nil {
v = viper.GetViper()
}
// viper.IsSet returns true if the key has been set in any of the
// data stores (flag, env, config file, default). We need to check
// whether the value was set at the global config level (not just
@@ -239,7 +265,7 @@ func isExplicitlySet(key string) bool {
// file values. This means global config file values (e.g.
// temperature: 0.7 at the top level) will correctly take precedence
// over model-level defaults, which is the desired behavior.
return viper.IsSet(key)
return v.IsSet(key)
}
// GenerationParams holds per-model generation parameter defaults.
File diff suppressed because one or more lines are too long
+83 -14
View File
@@ -48,18 +48,87 @@ type modelsDBLimit struct {
Output int `json:"output"`
}
// npmToLLMProvider maps npm package names from models.dev to LLM
// provider identifiers. Providers not in this map but with an api URL
// can be auto-routed through openaicompat.
var npmToLLMProvider = map[string]string{
"@ai-sdk/anthropic": "anthropic",
"@ai-sdk/openai": "openai",
"@ai-sdk/google": "google",
"@ai-sdk/google-vertex": "google-vertex",
"@ai-sdk/google-vertex/anthropic": "google-vertex-anthropic",
"@ai-sdk/amazon-bedrock": "bedrock",
"@ai-sdk/azure": "azure",
"@openrouter/ai-sdk-provider": "openrouter",
"@ai-sdk/vercel": "vercel",
"@ai-sdk/openai-compatible": "openaicompat",
// wireProtocol identifies which LLM API protocol an npm package speaks.
// Fantasy implements three native protocols (openai, anthropic, google);
// everything else in its providers/ tree is a thin wrapper around one of
// them with a pre-baked default URL or auth scheme.
type wireProtocol int
const (
wireUnknown wireProtocol = iota
wireOpenAI
wireAnthropic
wireGoogle
)
// npmToWireProtocol maps npm package names from models.dev to the wire
// protocol they speak. Provider-specific bundles that need bespoke auth or
// URL templating (azure, bedrock, openrouter, google-vertex, google-vertex-
// anthropic, and @ai-sdk/gateway which is the Vercel AI Gateway) are
// intentionally absent — they have native top-level cases in CreateProvider
// and never reach the auto-router. Providers not in this map but with an
// api URL are auto-routed through the OpenAI-compatible wire.
//
// The thin OpenAI-compatible npm wrappers (groq, cerebras, mistral, …) are
// listed explicitly so that auto-routing can recover their hard-coded base
// URL from sdkDefaultBaseURL when the registry entry has no api field.
var npmToWireProtocol = map[string]wireProtocol{
// Native wires.
"@ai-sdk/openai": wireOpenAI,
"@ai-sdk/openai-compatible": wireOpenAI,
"@ai-sdk/anthropic": wireAnthropic,
"@ai-sdk/google": wireGoogle,
// Thin OpenAI-compatible wrappers. Each ships with a hard-coded base URL
// in its JS SDK (see sdkDefaultBaseURL) but speaks the plain OpenAI chat
// completions wire — so we can route them all through fantasy's
// openaicompat provider once we supply the URL.
"@ai-sdk/groq": wireOpenAI,
"@ai-sdk/cerebras": wireOpenAI,
"@ai-sdk/perplexity": wireOpenAI,
"@ai-sdk/togetherai": wireOpenAI,
"@ai-sdk/xai": wireOpenAI,
"@ai-sdk/deepinfra": wireOpenAI,
"@ai-sdk/mistral": wireOpenAI,
"@ai-sdk/cohere": wireOpenAI,
"@ai-sdk/vercel": wireOpenAI, // v0 API (api.v0.dev), distinct from @ai-sdk/gateway
"@aihubmix/ai-sdk-provider": wireOpenAI,
"venice-ai-sdk-provider": wireOpenAI,
"merge-gateway-ai-sdk-provider": wireOpenAI,
}
// sdkDefaultBaseURL maps an npm package name to the base URL its JavaScript
// SDK uses by default. This lets us recover a working endpoint for providers
// whose models.dev entry omits the `api` field because the JS SDK hard-codes
// the URL (e.g. groq, cerebras, mistral, x.ai…).
//
// Only OpenAI-compatible and native-wire SDKs are listed; providers needing
// bespoke auth or URL templating (bedrock SigV4, azure resource URLs,
// google-vertex project/location, cloudflare gateway account IDs, gitlab,
// sap-ai-core) are handled by native CreateProvider cases or surface a
// targeted error that asks the user to supply --provider-url.
var sdkDefaultBaseURL = map[string]string{
// Native wires.
"@ai-sdk/openai": "https://api.openai.com/v1",
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
// Thin OpenAI-compatible wrappers.
"@ai-sdk/groq": "https://api.groq.com/openai/v1",
"@ai-sdk/cerebras": "https://api.cerebras.ai/v1",
"@ai-sdk/perplexity": "https://api.perplexity.ai",
"@ai-sdk/togetherai": "https://api.together.xyz/v1",
"@ai-sdk/xai": "https://api.x.ai/v1",
"@ai-sdk/deepinfra": "https://api.deepinfra.com/v1/openai",
"@ai-sdk/mistral": "https://api.mistral.ai/v1",
"@ai-sdk/cohere": "https://api.cohere.com/compatibility/v1",
"@ai-sdk/vercel": "https://api.v0.dev/v1",
"@aihubmix/ai-sdk-provider": "https://aihubmix.com/v1",
"venice-ai-sdk-provider": "https://api.venice.ai/api/v1",
"merge-gateway-ai-sdk-provider": "https://api-gateway.merge.dev/v1/ai-sdk",
// Native handlers — included for ResolveProviderBaseURL introspection
// even though CreateProvider routes these via dedicated cases.
"@ai-sdk/gateway": "https://ai-gateway.vercel.sh/v1",
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
}
-168
View File
@@ -1,168 +0,0 @@
package models
import (
"context"
"sync"
"time"
"charm.land/fantasy"
)
// ProviderPool manages reusable LLM provider instances to reduce overhead
// when spawning multiple subagents or making repeated completion calls.
type ProviderPool struct {
mu sync.RWMutex
providers map[string]*pooledProvider
ttl time.Duration
closed bool
closeCh chan struct{}
}
type pooledProvider struct {
model fantasy.LanguageModel
closer func() error
providerOpts fantasy.ProviderOptions
created time.Time
lastUsed time.Time
refs int32
}
// DefaultPoolTTL is the default time-to-live for idle pooled providers.
const DefaultPoolTTL = 5 * time.Minute
// globalPool is the singleton provider pool instance.
var globalPool *ProviderPool
var poolOnce sync.Once
// GetGlobalPool returns the singleton provider pool instance.
func GetGlobalPool() *ProviderPool {
poolOnce.Do(func() {
globalPool = NewProviderPool(DefaultPoolTTL)
})
return globalPool
}
// NewProviderPool creates a provider pool with the given TTL for idle providers.
func NewProviderPool(ttl time.Duration) *ProviderPool {
p := &ProviderPool{
providers: make(map[string]*pooledProvider),
ttl: ttl,
closeCh: make(chan struct{}),
}
go p.cleanupLoop()
return p
}
// Get returns a provider for the model string, creating one if needed.
// The returned release function must be called when the provider is no longer
// needed. The provider may be reused by subsequent Get calls.
func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) {
p.mu.Lock()
// Check if we have an existing provider.
if pp, ok := p.providers[modelString]; ok {
pp.refs++
pp.lastUsed = time.Now()
p.mu.Unlock()
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
p.mu.Unlock()
// Create a new provider outside the lock.
config := &ProviderConfig{ModelString: modelString}
result, err := CreateProvider(ctx, config)
if err != nil {
return nil, nil, nil, err
}
p.mu.Lock()
defer p.mu.Unlock()
// Double-check: another goroutine may have created one while we were unlocked.
if pp, ok := p.providers[modelString]; ok {
// Close the one we just created and use the existing one.
if result.Closer != nil {
_ = result.Closer.Close()
}
pp.refs++
pp.lastUsed = time.Now()
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
var closerFn func() error
if result.Closer != nil {
closerFn = result.Closer.Close
}
pp := &pooledProvider{
model: result.Model,
closer: closerFn,
providerOpts: result.ProviderOptions,
created: time.Now(),
lastUsed: time.Now(),
refs: 1,
}
p.providers[modelString] = pp
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
func (p *ProviderPool) release(modelString string) {
p.mu.Lock()
defer p.mu.Unlock()
if pp, ok := p.providers[modelString]; ok {
pp.refs--
pp.lastUsed = time.Now()
}
}
func (p *ProviderPool) cleanupLoop() {
ticker := time.NewTicker(p.ttl / 2)
defer ticker.Stop()
for {
select {
case <-p.closeCh:
return
case <-ticker.C:
p.cleanup()
}
}
}
func (p *ProviderPool) cleanup() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for key, pp := range p.providers {
// Only clean up providers with no active references and past TTL.
if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl {
if pp.closer != nil {
_ = pp.closer()
}
delete(p.providers, key)
}
}
}
// Close shuts down the pool and releases all providers.
func (p *ProviderPool) Close() {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return
}
p.closed = true
close(p.closeCh)
for key, pp := range p.providers {
if pp.closer != nil {
_ = pp.closer()
}
delete(p.providers, key)
}
p.mu.Unlock()
}
+551 -79
View File
@@ -9,8 +9,11 @@ import (
"io"
"maps"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
"charm.land/fantasy"
@@ -25,11 +28,30 @@ import (
openaisdk "github.com/charmbracelet/openai-go"
"github.com/mark3labs/kit/internal/auth"
"github.com/spf13/viper"
)
const (
// ClaudeCodePrompt is the required system prompt for OAuth authentication.
ClaudeCodePrompt = "You are Claude Code, Anthropic's official CLI for Claude."
// copilotProviderID is the canonical models.dev provider key. The CLI also
// accepts the shorter "copilot" alias for user-facing model strings.
copilotProviderID = "github-copilot"
// copilotAliasProviderID is the short provider prefix accepted by kit.
copilotAliasProviderID = "copilot"
// copilotBaseURL is the fallback API URL if the model catalog has no API URL.
copilotBaseURL = "https://api.githubcopilot.com"
// GitHub Copilot currently expects VS Code Copilot Chat client identifiers.
// Keep these centralized so they are easy to audit and update when GitHub
// changes accepted client metadata.
copilotIntegrationID = "vscode-chat"
copilotEditorVersion = "vscode/1.104.1"
copilotEditorPluginVersion = "copilot-chat/0.31.0"
copilotUserAgent = "GitHubCopilotChat/0.31.0"
copilotOpenAIIntent = "conversation-agent"
copilotGitHubAPIVersion = "2026-01-09"
)
// resolveModelAlias resolves model aliases to their full names using the registry
@@ -85,6 +107,7 @@ type ThinkingLevel string
const (
ThinkingOff ThinkingLevel = "off"
ThinkingNone ThinkingLevel = "none"
ThinkingMinimal ThinkingLevel = "minimal"
ThinkingLow ThinkingLevel = "low"
ThinkingMedium ThinkingLevel = "medium"
@@ -93,12 +116,14 @@ const (
// ThinkingLevels returns the ordered list of available thinking levels for cycling.
func ThinkingLevels() []ThinkingLevel {
return []ThinkingLevel{ThinkingOff, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
return []ThinkingLevel{ThinkingOff, ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
}
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off" or "none".
func thinkingBudgetTokens(level ThinkingLevel) int64 {
switch level {
case ThinkingNone:
return 1024
case ThinkingMinimal:
return 1024
case ThinkingLow:
@@ -117,6 +142,8 @@ func ThinkingLevelDescription(level ThinkingLevel) string {
switch level {
case ThinkingOff:
return "No reasoning"
case ThinkingNone:
return "Minimal reasoning (OpenAI 'none')"
case ThinkingMinimal:
return "Very brief reasoning (~1k tokens)"
case ThinkingLow:
@@ -133,7 +160,7 @@ func ThinkingLevelDescription(level ThinkingLevel) string {
// ParseThinkingLevel converts a string to a ThinkingLevel, defaulting to ThinkingOff.
func ParseThinkingLevel(s string) ThinkingLevel {
switch ThinkingLevel(s) {
case ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
case ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
return ThinkingLevel(s)
default:
return ThinkingOff
@@ -159,6 +186,13 @@ type ProviderConfig struct {
ThinkingLevel ThinkingLevel
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
// ConfigStore is the per-instance configuration store used to resolve
// "explicitly set" precedence checks (isExplicitlySet), per-model
// settings, and right-sizing. When nil, the process-global viper store is
// used. Threading a per-Kit store here keeps generation-parameter
// precedence isolated between Kit instances in the same process.
ConfigStore *viper.Viper
// ProgressReaderFunc, when set, wraps an io.Reader with progress display
// for long operations like Ollama model pulls. The returned io.ReadCloser
// must be closed when done. When nil, the raw reader is consumed directly
@@ -200,6 +234,20 @@ func ParseModelString(modelString string) (provider, model string, err error) {
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
}
// isCopilotProvider reports whether provider is the canonical catalog key or
// the user-facing shorthand alias.
func isCopilotProvider(provider string) bool {
return provider == copilotAliasProviderID || provider == copilotProviderID
}
// catalogProviderID maps supported provider aliases to their models.dev keys.
func catalogProviderID(provider string) string {
if isCopilotProvider(provider) {
return copilotProviderID
}
return provider
}
// CreateProvider creates a fantasy LanguageModel based on the provider configuration.
// Model metadata is looked up from the models.dev database for cost tracking and
// capability detection, but unknown models are passed through to the provider
@@ -207,8 +255,10 @@ func ParseModelString(modelString string) (provider, model string, err error) {
//
// Native providers: anthropic, openai, google, ollama, azure, google-vertex-anthropic,
// openrouter, bedrock, vercel.
// Any provider in models.dev with an api URL or openai-compatible npm package
// is auto-routed through fantasy's openaicompat provider.
// Any other provider in models.dev is auto-routed by wire protocol: its npm
// package (or per-model override) selects the OpenAI, Anthropic, or Google
// transport, using the provider's api URL as the base. Providers with an api
// URL but an unrecognized npm package fall back to the OpenAI-compatible wire.
func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResult, error) {
provider, modelName, err := ParseModelString(config.ModelString)
if err != nil {
@@ -221,17 +271,30 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
}
registry := GetGlobalRegistry()
lookupProvider := catalogProviderID(provider)
// Look up model metadata (advisory, not blocking).
// Look up model metadata (advisory for most providers, strict for Copilot).
// When the model is known we validate config limits and print
// suggestions on likely typos; when unknown we let the provider
// API be the authority.
modelInfo := registry.LookupModel(provider, modelName)
if modelInfo == nil && provider != "ollama" && config.ProviderURL == "" {
// API be the authority except for Copilot, whose non-GPT catalog entries
// require unsupported wire protocols.
modelInfo := registry.LookupModel(lookupProvider, modelName)
if isCopilotProvider(provider) {
providerInfo := registry.GetProviderInfo(copilotProviderID)
if providerInfo == nil {
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", copilotProviderID)
}
if modelInfo == nil {
if suggestions := registry.SuggestModels(copilotProviderID, modelName); len(suggestions) > 0 {
return nil, fmt.Errorf("model %q not found for provider %s. Did you mean one of: %s", modelName, copilotProviderID, strings.Join(suggestions, ", "))
}
return nil, fmt.Errorf("model %q not found for provider %s", modelName, copilotProviderID)
}
} else if modelInfo == nil && provider != "ollama" && config.ProviderURL == "" {
// Model not in database — warn with suggestions but don't block.
if suggestions := registry.SuggestModels(provider, modelName); len(suggestions) > 0 {
if suggestions := registry.SuggestModels(lookupProvider, modelName); len(suggestions) > 0 {
fmt.Fprintf(os.Stderr, "Warning: model %q not found in model database for provider %s. Similar models: %s\n",
modelName, provider, strings.Join(suggestions, ", "))
modelName, lookupProvider, strings.Join(suggestions, ", "))
}
}
@@ -251,6 +314,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
// via CLI flag or global config.
ApplyModelSettings(config, modelInfo)
// Auto-raise MaxTokens toward the model's known output ceiling when the
// user hasn't explicitly set --max-tokens and no per-model override
// applied. Runs after ApplyModelSettings so explicit modelSettings win.
rightSizeMaxTokens(config, modelInfo)
// Create the base provider
var result *ProviderResult
var createErr error
@@ -260,17 +328,21 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
result, createErr = createAnthropicProvider(ctx, config, modelName)
case "openai":
result, createErr = createOpenAIProvider(ctx, config, modelName)
case "copilot", "github-copilot":
result, createErr = createCopilotProvider(ctx, config, modelName)
case "google", "gemini":
result, createErr = createGoogleProvider(ctx, config, modelName)
case "ollama":
result, createErr = createOllamaProvider(ctx, config, modelName)
case "azure":
case "azure", "azure-cognitive-services":
result, createErr = createAzureProvider(ctx, config, modelName)
case "google-vertex-anthropic":
result, createErr = createVertexAnthropicProvider(ctx, config, modelName)
case "google-vertex":
result, createErr = createGoogleVertexProvider(ctx, config, modelName)
case "openrouter":
result, createErr = createOpenRouterProvider(ctx, config, modelName)
case "bedrock":
case "bedrock", "amazon-bedrock":
result, createErr = createBedrockProvider(ctx, config, modelName)
case "vercel":
result, createErr = createVercelProvider(ctx, config, modelName)
@@ -295,9 +367,18 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
// Only add cache options for providers that don't already have
// options set, to avoid type conflicts (e.g., Anthropic has
// different types for regular options vs cache control options).
for k, v := range cacheOpts {
if _, exists := result.ProviderOptions[k]; !exists {
result.ProviderOptions[k] = v
//
// For OpenAI Responses API models, we skip merging entirely because
// ResponsesProviderOptions and ProviderOptions are incompatible types.
skipMerge := false
if provider == "openai" && openai.IsResponsesModel(modelName) {
skipMerge = true
}
if !skipMerge {
for k, v := range cacheOpts {
if _, exists := result.ProviderOptions[k]; !exists {
result.ProviderOptions[k] = v
}
}
}
}
@@ -308,44 +389,100 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
// autoRouteProvider attempts to create a provider by looking up its npm package
// in the models.dev database and routing through the appropriate fantasy provider.
// For openai-compatible providers, it uses the api URL from models.dev.
// Models may have a provider override that specifies a different npm package than
// the provider's default (e.g., opencode's claude-opus-4-6 uses @ai-sdk/anthropic).
// It routes on wire protocol (openai, anthropic, google) rather than per-npm
// provider name: fantasy implements three native wire protocols, and every other
// entry in its providers/ tree is a thin wrapper around one of them. Using the
// provider's api URL from models.dev as the base URL, any proxy that re-flavors
// one of these protocols (e.g. opencode's Gemini routes) Just Works.
//
// Models may carry a provider override that specifies a different npm package
// than the provider's default (e.g. opencode's claude-* uses @ai-sdk/anthropic
// and its gemini-* uses @ai-sdk/google), which is resolved first.
func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, modelName string, registry *ModelsRegistry) (*ProviderResult, error) {
providerInfo := registry.GetProviderInfo(provider)
if providerInfo == nil {
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", provider)
}
// Check for model-specific provider override
// Resolve npm: per-model override > provider default.
npmPackage := providerInfo.NPM
if modelInfo := registry.LookupModel(provider, modelName); modelInfo != nil && modelInfo.ProviderNPM != "" {
npmPackage = modelInfo.ProviderNPM
}
// Determine the LLM provider for this npm package
llmProvider := npmToLLMProvider[npmPackage]
if llmProvider == "" && providerInfo.API != "" {
// Unknown npm but has API URL → route through openaicompat
llmProvider = "openaicompat"
wire, known := npmToWireProtocol[npmPackage]
if !known {
// Unknown npm but the provider has an API URL → assume OpenAI-compatible.
// (Preserves the long-standing "any provider in models.dev with an api URL
// is auto-routed through openaicompat" behaviour.)
if providerInfo.API == "" {
return nil, fmt.Errorf(
"cannot auto-route provider %s: npm package %q has no known wire protocol "+
"and the registry has no API URL (use --provider-url to override)",
provider, npmPackage,
)
}
wire = wireOpenAI
}
switch llmProvider {
case "openaicompat":
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case "anthropic":
if config.ProviderURL == "" && providerInfo.API != "" {
// All three wires use the provider's API URL from models.dev as the base.
// When the registry has none, fall back to the SDK's hard-coded default for
// this npm package (covers groq, cerebras, mistral, x.ai, etc. — providers
// whose JS SDK ships a built-in baseURL that models.dev doesn't restate).
if config.ProviderURL == "" {
if providerInfo.API != "" {
config.ProviderURL = providerInfo.API
} else if defaultURL, ok := sdkDefaultBaseURL[npmPackage]; ok {
config.ProviderURL = defaultURL
providerInfo.API = defaultURL // for downstream helpers that read info.API
}
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
case "openai":
if config.ProviderURL == "" && providerInfo.API != "" {
config.ProviderURL = providerInfo.API
}
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage)
}
// Provider templates a runtime account/region/deployment segment into the
// URL (cloudflare-ai-gateway, databricks, snowflake-cortex, gitlab,
// sap-ai-core). Resolve via environment variables, or surface a targeted
// error pointing the user at the right knobs.
if resolved, err := resolveTemplatedAPIURL(config.ProviderURL, providerInfo); err != nil {
return nil, err
} else if resolved != "" {
config.ProviderURL = resolved
providerInfo.API = resolved
}
switch wire {
case wireOpenAI:
// The native OpenAI SDK package (@ai-sdk/openai) speaks the Responses
// API; openai-compatible proxies (and unknown-npm fallbacks) use the
// chat-completions wire via fantasy's openaicompat provider.
if npmPackage == "@ai-sdk/openai" {
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
}
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case wireAnthropic:
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
case wireGoogle:
return createAutoRoutedGoogleProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("internal error: unknown wire protocol for provider %s (npm: %s)", provider, npmPackage)
}
}
// resolveAutoRouteAPIKey looks up the API key for an auto-routed provider,
// returning a uniform error message when none can be resolved.
func resolveAutoRouteAPIKey(config *ProviderConfig, info *ProviderInfo) (string, error) {
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return "", fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
}
return apiKey, nil
}
// wrapProviderErr produces the uniform "failed to create X provider/model: %w"
// error wrap used by every createXxxProvider path. kind is typically
// "provider" or "model".
func wrapProviderErr(name, kind string, err error) error {
return fmt.Errorf("failed to create %s %s: %w", name, kind, err)
}
// createAutoRoutedOpenAICompatProvider creates an openaicompat provider using
@@ -359,10 +496,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
return nil, fmt.Errorf("provider %s requires --provider-url (no API URL in database)", info.ID)
}
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
var opts []openaicompat.Option
@@ -376,12 +512,12 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
p, err := openaicompat.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -392,10 +528,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
clearConflictingAnthropicSamplingParams(config)
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
var opts []anthropic.Option
@@ -414,12 +549,12 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
p, err := anthropic.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -428,10 +563,9 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
// createAutoRoutedOpenAIProvider creates an openai provider for
// third-party providers with openai-compatible APIs.
func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
var opts []openai.Option
@@ -448,12 +582,12 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
p, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "model", err)
}
providerOpts := buildOpenAIProviderOptions(config, modelName)
@@ -461,6 +595,114 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
}
// createAutoRoutedGoogleProvider creates a Google (Gemini) provider for
// third-party providers that expose a Gemini-compatible API (e.g. opencode's
// Gemini routes, which carry an @ai-sdk/google per-model override).
//
// The underlying genai SDK always injects its own API version segment
// ("v1beta") between the base URL and the resource path. When the proxy's
// base URL from models.dev already carries a version segment (e.g. opencode's
// https://opencode.ai/zen/v1), that produces a doubled ".../v1/v1beta/..."
// path that the proxy rejects. In that case we install a transport that
// strips the injected segment so the proxy's own version is used.
func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
opts := []google.Option{
google.WithGeminiAPIKey(apiKey),
google.WithName(info.ID),
}
if config.ProviderURL != "" {
opts = append(opts, google.WithBaseURL(config.ProviderURL))
}
// Decide whether the genai-injected version segment needs stripping.
var httpClient *http.Client
if basePath := versionedBasePath(config.ProviderURL); basePath != "" {
httpClient = newGeminiProxyHTTPClient(basePath, config.TLSSkipVerify)
} else if config.TLSSkipVerify {
httpClient = createHTTPClientWithTLSConfig(true)
}
if httpClient != nil {
opts = append(opts, google.WithHTTPClient(httpClient))
}
p, err := google.New(opts...)
if err != nil {
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, wrapProviderErr(info.Name, "model", err)
}
return &ProviderResult{Model: model}, nil
}
// versionSegmentRe matches a trailing API version segment in a URL path,
// e.g. "/v1", "/v1beta", "/v1beta1", "/v2alpha".
var versionSegmentRe = regexp.MustCompile(`/v\d+(?:beta\d*|alpha\d*)?$`)
// versionedBasePath returns the path component of rawURL when that path ends
// with an API version segment (e.g. opencode's ".../zen/v1" → "/zen/v1").
// It returns "" when rawURL is empty, unparseable, or has no version suffix
// — in which case the genai SDK's default version injection is correct and
// no rewriting is needed.
func versionedBasePath(rawURL string) string {
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
path := strings.TrimSuffix(u.Path, "/")
if versionSegmentRe.MatchString(path) {
return path
}
return ""
}
// newGeminiProxyHTTPClient builds an HTTP client whose transport strips the
// genai-injected version segment ("v1beta"/"v1beta1") that directly follows
// basePath, collapsing "{basePath}/v1beta/..." back to "{basePath}/...".
func newGeminiProxyHTTPClient(basePath string, skipVerify bool) *http.Client {
var base http.RoundTripper
if skipVerify {
base = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
} else {
base = http.DefaultTransport
}
return &http.Client{
Transport: &geminiProxyTransport{base: base, basePath: basePath},
}
}
// geminiProxyTransport removes the redundant API version segment that the
// genai SDK injects after a proxy base URL that already carries its own
// version segment.
type geminiProxyTransport struct {
base http.RoundTripper
basePath string
}
func (t *geminiProxyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for _, injected := range []string{"/v1beta1", "/v1beta"} {
prefix := t.basePath + injected + "/"
if strings.HasPrefix(req.URL.Path, prefix) {
newReq := req.Clone(req.Context())
newReq.URL.Path = t.basePath + strings.TrimPrefix(req.URL.Path, t.basePath+injected)
return t.base.RoundTrip(newReq)
}
}
return t.base.RoundTrip(req)
}
// resolveAPIKey returns the first non-empty API key from the explicit key
// or the environment variables.
func resolveAPIKey(explicitKey string, envVars []string) string {
@@ -489,6 +731,37 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) {
}
}
// defaultRightSizeCap bounds auto-raised MaxTokens so that we don't silently
// allocate enormous output budgets for models with very high ceilings (e.g.
// Devstral at 262144, Mistral at 128000). Users who genuinely want more can
// pass --max-tokens explicitly or set modelSettings[...].maxTokens in config.
const defaultRightSizeCap = 32768
// rightSizeMaxTokens raises config.MaxTokens toward the model's known output
// ceiling when:
// - the user has not explicitly set --max-tokens (or the KIT_MAX_TOKENS env
// var, or the top-level max-tokens key in config.yaml), AND
// - no per-model override already bumped MaxTokens (ApplyModelSettings runs
// before this function), AND
// - modelInfo.Limit.Output is known and larger than the current MaxTokens.
//
// The raised value is capped at defaultRightSizeCap to keep accidental
// allocations reasonable on very-large-output models. This prevents the
// common "ghost" where the agent's reply is silently truncated at the 8192
// default even though the selected model supports 64k or 262k output tokens.
func rightSizeMaxTokens(config *ProviderConfig, modelInfo *ModelInfo) {
if modelInfo == nil || modelInfo.Limit.Output <= 0 {
return
}
if isExplicitlySet(config.ConfigStore, "max-tokens") {
return
}
target := min(modelInfo.Limit.Output, defaultRightSizeCap)
if config.MaxTokens < target {
config.MaxTokens = target
}
}
// clearConflictingAnthropicSamplingParams ensures that temperature and top_p are
// not both sent to the Anthropic API, which rejects requests containing both.
// When both are set (typically from defaults), top_p is cleared so that
@@ -535,6 +808,8 @@ func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantas
// Returns nil for ThinkingOff (use the model's default).
func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort {
switch level {
case ThinkingNone:
return new(openai.ReasoningEffortNone)
case ThinkingMinimal:
return new(openai.ReasoningEffortMinimal)
case ThinkingLow:
@@ -548,6 +823,56 @@ func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort
}
}
// IsValidThinkingLevelForModel checks if a thinking level is valid for the given
// model. Some OpenAI models like gpt-5.4 don't support "minimal" and require
// "none" instead.
func IsValidThinkingLevelForModel(level ThinkingLevel, modelName string) bool {
if level == ThinkingOff {
return true
}
// Check if this is an OpenAI model that doesn't support "minimal"
// gpt-5.4 and newer gpt-5.x models use "none" instead of "minimal"
if level == ThinkingMinimal {
if strings.Contains(modelName, "gpt-5.4") ||
strings.Contains(modelName, "gpt-5-pro") ||
strings.Contains(modelName, "gpt-5-chat") {
return false
}
}
// Check if this is an OpenAI model that doesn't support "none"
// Older gpt-5 models only support "minimal", not "none"
if level == ThinkingNone {
if strings.Contains(modelName, "gpt-5") &&
!strings.Contains(modelName, "gpt-5.4") &&
!strings.Contains(modelName, "gpt-5-pro") &&
!strings.Contains(modelName, "gpt-5-chat") {
// Older gpt-5 models might not support "none"
// They only added "none" support in newer versions
return false
}
}
// All other levels are generally valid for reasoning models
return true
}
// SuggestThinkingLevelFallback returns a recommended fallback level when the
// requested level is not valid for the model. Returns ThinkingOff if no
// suitable fallback exists.
func SuggestThinkingLevelFallback(level ThinkingLevel, modelName string) ThinkingLevel {
if level == ThinkingMinimal && !IsValidThinkingLevelForModel(level, modelName) {
// For models that don't support "minimal", suggest "none" (~same token budget)
return ThinkingNone
}
if level == ThinkingNone && !IsValidThinkingLevelForModel(level, modelName) {
// For models that don't support "none", suggest "minimal" (~same token budget)
return ThinkingMinimal
}
return ThinkingOff
}
// buildAnthropicProviderOptions returns fantasy.ProviderOptions configured for
// Anthropic models with extended thinking. When thinking is enabled, it sets
// SendReasoning to true and configures the thinking budget. For thinking-off
@@ -607,7 +932,7 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
}
// Handle OAuth vs API key authentication
if strings.HasPrefix(source, "stored OAuth") {
if source == auth.CredentialSourceOAuth {
httpClient := createOAuthHTTPClient(apiKey, config.TLSSkipVerify)
opts = append(opts, anthropic.WithHTTPClient(httpClient))
// Note: For OAuth, the API key is set as a placeholder; the transport handles auth
@@ -617,12 +942,12 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
provider, err := anthropic.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Anthropic provider: %w", err)
return nil, wrapProviderErr("Anthropic", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
return nil, wrapProviderErr("Anthropic", "model", err)
}
// Build provider options for extended thinking (reasoning budget).
@@ -659,12 +984,12 @@ func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig,
provider, err := anthropic.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Vertex Anthropic provider: %w", err)
return nil, wrapProviderErr("Vertex Anthropic", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Vertex Anthropic model: %w", err)
return nil, wrapProviderErr("Vertex Anthropic", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -732,12 +1057,12 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI provider: %w", err)
return nil, wrapProviderErr("OpenAI", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
return nil, wrapProviderErr("OpenAI", "model", err)
}
// Build provider options for OpenAI Responses API reasoning models.
@@ -746,6 +1071,72 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
}
// createCopilotProvider builds a GitHub Copilot provider through fantasy's
// OpenAI-compatible provider. The catalog key is github-copilot, but the public
// model prefix may be either copilot/ or github-copilot/.
//
// Only gpt-* Copilot models are enabled here. The catalog also lists Claude and
// Gemini Copilot models, but those require different wire protocols and must be
// routed explicitly before they can be safely accepted.
func createCopilotProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
if !strings.HasPrefix(modelName, "gpt-") {
return nil, fmt.Errorf("GitHub Copilot model %q is not supported yet: only gpt-* models use the OpenAI-compatible protocol", modelName)
}
cm, err := auth.NewCredentialManager()
if err != nil {
return nil, fmt.Errorf("failed to initialize credential manager: %w", err)
}
token, err := cm.GetValidCopilotAccessTokenContext(ctx)
if err != nil {
return nil, fmt.Errorf("GitHub Copilot credentials not available. Use 'kit auth login copilot': %w", err)
}
expiresAt := int64(0)
if creds, err := cm.GetCopilotCredentials(); err == nil && creds != nil && creds.CopilotAccessToken == token {
expiresAt = creds.ExpiresAt
}
baseURL := copilotBaseURL
if providerInfo := GetGlobalRegistry().GetProviderInfo(copilotProviderID); providerInfo != nil && providerInfo.API != "" {
baseURL = providerInfo.API
}
if config.ProviderURL != "" {
baseURL = config.ProviderURL
}
opts := []openai.Option{
openai.WithName(copilotAliasProviderID),
openai.WithBaseURL(baseURL),
openai.WithAPIKey(token),
openai.WithHTTPClient(createCopilotHTTPClient(token, expiresAt, config.TLSSkipVerify)),
openai.WithUseResponsesAPI(),
openai.WithResponsesAPIFunc(copilotUsesResponsesAPI),
openai.WithObjectMode(fantasy.ObjectModeTool),
}
provider, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub Copilot provider: %w", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub Copilot model: %w", err)
}
providerOpts := buildOpenAIProviderOptions(config, modelName)
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
}
// copilotUsesResponsesAPI selects the OpenAI Responses API for Copilot models
// known to support it. Non-gpt models are rejected before provider creation.
func copilotUsesResponsesAPI(modelID string) bool {
return strings.HasPrefix(modelID, "gpt-5")
}
// createOpenAICodexProvider creates a provider for ChatGPT/Codex OAuth tokens.
// Uses the chatgpt.com/backend-api/codex endpoint with special headers.
func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, modelName, token, accountID string) (*ProviderResult, error) {
@@ -773,12 +1164,12 @@ func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, mode
provider, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI Codex provider: %w", err)
return nil, wrapProviderErr("OpenAI Codex", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI Codex model: %w", err)
return nil, wrapProviderErr("OpenAI Codex", "model", err)
}
providerOpts := buildCodexProviderOptions(config, modelName)
@@ -875,6 +1266,87 @@ func (t *codexTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.base.RoundTrip(newReq)
}
// createCopilotHTTPClient returns an HTTP client that injects Copilot-specific
// authorization and client metadata headers. The token and expiry are cached in
// the transport so streaming requests do not hit credentials.json on every
// RoundTrip; the credential manager is consulted only near expiry.
func createCopilotHTTPClient(token string, expiresAt int64, skipVerify bool) *http.Client {
var base http.RoundTripper
if skipVerify {
base = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
} else {
base = http.DefaultTransport
}
return &http.Client{
Transport: &copilotTransport{
base: base,
token: token,
expiresAt: expiresAt,
},
Timeout: 120 * time.Second,
}
}
// copilotTransport decorates requests for api.githubcopilot.com.
//
// It owns a cached Copilot access token. When the token is still valid, the hot
// path is in-memory only. Near expiry it refreshes through CredentialManager,
// which updates both the cache here and credentials.json.
type copilotTransport struct {
base http.RoundTripper
token string
expiresAt int64
mu sync.Mutex
}
func (t *copilotTransport) RoundTrip(req *http.Request) (*http.Response, error) {
token := t.cachedToken(req.Context())
newReq := req.Clone(req.Context())
newReq.Header.Set("Authorization", "Bearer "+token)
newReq.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
newReq.Header.Set("Editor-Version", copilotEditorVersion)
newReq.Header.Set("Editor-Plugin-Version", copilotEditorPluginVersion)
newReq.Header.Set("Openai-Intent", copilotOpenAIIntent)
newReq.Header.Set("User-Agent", copilotUserAgent)
newReq.Header.Set("X-GitHub-Api-Version", copilotGitHubAPIVersion)
return t.base.RoundTrip(newReq)
}
// cachedToken returns the cached token unless it is within the five-minute
// refresh window. Refresh errors fall back to the last token so the request can
// surface any authoritative auth failure from the Copilot API.
func (t *copilotTransport) cachedToken(ctx context.Context) string {
t.mu.Lock()
defer t.mu.Unlock()
if t.expiresAt == 0 || time.Now().Unix() < t.expiresAt-300 {
return t.token
}
cm, err := auth.NewCredentialManager()
if err != nil {
return t.token
}
fresh, err := cm.GetValidCopilotAccessTokenContext(ctx)
if err != nil || fresh == "" {
return t.token
}
t.token = fresh
if creds, err := cm.GetCopilotCredentials(); err == nil && creds != nil && creds.CopilotAccessToken == fresh {
t.expiresAt = creds.ExpiresAt
}
return t.token
}
func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
apiKey := firstNonEmpty(
config.ProviderAPIKey,
@@ -891,12 +1363,12 @@ func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := google.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Google provider: %w", err)
return nil, wrapProviderErr("Google", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Google model: %w", err)
return nil, wrapProviderErr("Google", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -929,12 +1401,12 @@ func createAzureProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := azure.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Azure OpenAI provider: %w", err)
return nil, wrapProviderErr("Azure OpenAI", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Azure OpenAI model: %w", err)
return nil, wrapProviderErr("Azure OpenAI", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -954,12 +1426,12 @@ func createOpenRouterProvider(ctx context.Context, config *ProviderConfig, model
provider, err := openrouter.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create OpenRouter provider: %w", err)
return nil, wrapProviderErr("OpenRouter", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create OpenRouter model: %w", err)
return nil, wrapProviderErr("OpenRouter", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -971,12 +1443,12 @@ func createBedrockProvider(ctx context.Context, config *ProviderConfig, modelNam
// Bedrock uses AWS SDK default credential chain (env vars, shared config, etc.)
provider, err := bedrock.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Bedrock provider: %w", err)
return nil, wrapProviderErr("Bedrock", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Bedrock model: %w", err)
return nil, wrapProviderErr("Bedrock", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1000,12 +1472,12 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := vercel.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Vercel provider: %w", err)
return nil, wrapProviderErr("Vercel", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Vercel model: %w", err)
return nil, wrapProviderErr("Vercel", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1058,12 +1530,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
p, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create custom provider: %w", err)
return nil, wrapProviderErr("custom", "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create custom model: %w", err)
return nil, wrapProviderErr("custom", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1107,12 +1579,12 @@ func createOllamaProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := openaicompat.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Ollama provider: %w", err)
return nil, wrapProviderErr("Ollama", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Ollama model: %w", err)
return nil, wrapProviderErr("Ollama", "model", err)
}
return &ProviderResult{
+41 -8
View File
@@ -4,6 +4,7 @@ import (
_ "embed"
"encoding/json"
"fmt"
"maps"
"os"
"strings"
@@ -111,13 +112,30 @@ func NewModelsRegistry() *ModelsRegistry {
}
// buildFromModelsDB converts models.dev provider data into our internal format.
// It tries the on-disk cache first and falls back to the embedded database.
// It starts from the compile-time embedded database and merges on-disk cached
// data from `kit update-models` on top. Cached provider metadata replaces
// embedded metadata, and model entries are merged with cached models taking
// precedence. This means newly synced models are available while embedded
// models that haven't been synced yet are still reachable.
func buildFromModelsDB() map[string]ProviderInfo {
// Try cached data first (from `kit update-models`)
dbProviders, _ := LoadCachedProviders()
if len(dbProviders) == 0 {
// Fall back to compile-time embedded data
dbProviders = loadEmbeddedProviders()
// Start with compile-time embedded data as the base.
dbProviders := loadEmbeddedProviders()
if dbProviders == nil {
dbProviders = make(ModelsDBProviders)
}
// Merge on-disk cached data on top (cached takes precedence).
if cached, _ := LoadCachedProviders(); len(cached) > 0 {
for providerID, cp := range cached {
if existing, ok := dbProviders[providerID]; ok {
// Merge models: embedded base + cached overrides.
mergedModels := make(map[string]modelsDBModel, len(existing.Models)+len(cp.Models))
maps.Copy(mergedModels, existing.Models)
maps.Copy(mergedModels, cp.Models)
cp.Models = mergedModels
}
dbProviders[providerID] = cp
}
}
providers := make(map[string]ProviderInfo, len(dbProviders))
@@ -228,6 +246,7 @@ func loadEmbeddedProviders() map[string]modelsDBProvider {
// doesn't track yet. Callers should treat a nil return as "unknown model"
// and continue with sensible defaults.
func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil
@@ -255,6 +274,7 @@ func LookupModelForSettings(modelString string) *ModelInfo {
// getRequiredEnvVars returns the required environment variables for a provider.
func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil, fmt.Errorf("unsupported provider: %s", provider)
@@ -269,6 +289,7 @@ func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
// variables. Returns nil for providers not in the registry (unknown
// providers are assumed to handle auth themselves or via --provider-api-key).
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
provider = catalogProviderID(provider)
if apiKey != "" {
return nil
}
@@ -293,6 +314,15 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
}
}
// For GitHub Copilot, check stored GitHub OAuth credentials.
if provider == copilotProviderID {
if cm, err := auth.NewCredentialManager(); err == nil {
if has, _ := cm.HasCopilotCredentials(); has {
return nil
}
}
}
envVars, err := r.getRequiredEnvVars(provider)
if err != nil {
// Unknown provider — nothing to validate
@@ -332,6 +362,7 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
// SuggestModels returns similar model names when an invalid model is provided.
func (r *ModelsRegistry) SuggestModels(provider, invalidModel string) []string {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil
@@ -386,8 +417,8 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
return true
}
// Check if npm maps to an LLM provider
if _, ok := npmToLLMProvider[info.NPM]; ok {
// Check if npm maps to a known wire protocol
if _, ok := npmToWireProtocol[info.NPM]; ok {
return true
}
@@ -397,6 +428,7 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
// GetModelsForProvider returns all models for a specific provider.
func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]ModelInfo, error) {
provider = catalogProviderID(provider)
providerInfo, exists := r.providers[provider]
if !exists {
return nil, fmt.Errorf("unsupported provider: %s", provider)
@@ -407,6 +439,7 @@ func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]Model
// GetProviderInfo returns the full provider info, or nil if not found.
func (r *ModelsRegistry) GetProviderInfo(provider string) *ProviderInfo {
provider = catalogProviderID(provider)
info, exists := r.providers[provider]
if !exists {
return nil
+148
View File
@@ -0,0 +1,148 @@
package models
import (
"testing"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
// bindMaxTokensFlag wires a fresh pflag-backed "max-tokens" key into viper so
// isExplicitlySet behaves the same way it does in production. Returns a
// cleanup function that removes the binding so sibling tests see a clean
// state.
func bindMaxTokensFlag(t *testing.T, args []string) func() {
t.Helper()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
fs.Int("max-tokens", 8192, "")
if err := viper.BindPFlag("max-tokens", fs.Lookup("max-tokens")); err != nil {
t.Fatalf("BindPFlag: %v", err)
}
if err := fs.Parse(args); err != nil {
t.Fatalf("fs.Parse: %v", err)
}
return func() {
viper.Reset()
}
}
func TestRightSizeMaxTokens_RaisesWhenBelowCeiling(t *testing.T) {
cleanup := bindMaxTokensFlag(t, nil) // no args → flag.Changed = false
defer cleanup()
config := &ProviderConfig{MaxTokens: 8192}
modelInfo := &ModelInfo{
ID: "claude-sonnet-4-5",
Limit: Limit{Context: 200000, Output: 64000},
}
rightSizeMaxTokens(config, modelInfo)
if config.MaxTokens != 32768 {
t.Errorf("expected MaxTokens raised to defaultRightSizeCap (32768), got %d", config.MaxTokens)
}
}
func TestRightSizeMaxTokens_CapsAtDefaultRightSizeCap(t *testing.T) {
cleanup := bindMaxTokensFlag(t, nil)
defer cleanup()
config := &ProviderConfig{MaxTokens: 8192}
// Mistral Devstral has 262144 output — we should still cap at 32768.
modelInfo := &ModelInfo{
ID: "devstral-medium-latest",
Limit: Limit{Context: 262144, Output: 262144},
}
rightSizeMaxTokens(config, modelInfo)
if config.MaxTokens != defaultRightSizeCap {
t.Errorf("expected MaxTokens capped at %d, got %d", defaultRightSizeCap, config.MaxTokens)
}
}
func TestRightSizeMaxTokens_UsesExactOutputWhenBelowCap(t *testing.T) {
cleanup := bindMaxTokensFlag(t, nil)
defer cleanup()
config := &ProviderConfig{MaxTokens: 4096}
// Model with output limit smaller than the cap.
modelInfo := &ModelInfo{
ID: "gpt-4",
Limit: Limit{Context: 8192, Output: 8192},
}
rightSizeMaxTokens(config, modelInfo)
if config.MaxTokens != 8192 {
t.Errorf("expected MaxTokens raised to model output ceiling (8192), got %d", config.MaxTokens)
}
}
func TestRightSizeMaxTokens_DoesNotLowerCurrentValue(t *testing.T) {
cleanup := bindMaxTokensFlag(t, nil)
defer cleanup()
// User (via per-model settings, applied earlier) already bumped MaxTokens
// above the cap — we must not clobber their choice.
config := &ProviderConfig{MaxTokens: 100000}
modelInfo := &ModelInfo{
ID: "devstral-medium-latest",
Limit: Limit{Context: 262144, Output: 262144},
}
rightSizeMaxTokens(config, modelInfo)
if config.MaxTokens != 100000 {
t.Errorf("expected MaxTokens preserved at 100000, got %d", config.MaxTokens)
}
}
func TestRightSizeMaxTokens_RespectsExplicitFlag(t *testing.T) {
// Simulate `--max-tokens 4096` on the command line.
cleanup := bindMaxTokensFlag(t, []string{"--max-tokens", "4096"})
defer cleanup()
config := &ProviderConfig{MaxTokens: 4096}
modelInfo := &ModelInfo{
ID: "claude-sonnet-4-5",
Limit: Limit{Context: 200000, Output: 64000},
}
rightSizeMaxTokens(config, modelInfo)
if config.MaxTokens != 4096 {
t.Errorf("expected explicit --max-tokens to be preserved (4096), got %d", config.MaxTokens)
}
}
func TestRightSizeMaxTokens_NilModelInfo(t *testing.T) {
cleanup := bindMaxTokensFlag(t, nil)
defer cleanup()
config := &ProviderConfig{MaxTokens: 8192}
// Custom model / Ollama / unknown provider → no model info.
rightSizeMaxTokens(config, nil)
if config.MaxTokens != 8192 {
t.Errorf("expected MaxTokens unchanged with nil modelInfo, got %d", config.MaxTokens)
}
}
func TestRightSizeMaxTokens_ZeroOutputLimit(t *testing.T) {
cleanup := bindMaxTokensFlag(t, nil)
defer cleanup()
config := &ProviderConfig{MaxTokens: 8192}
// Model present in catalog but with no known output limit.
modelInfo := &ModelInfo{
ID: "unknown-model",
Limit: Limit{Context: 0, Output: 0},
}
rightSizeMaxTokens(config, modelInfo)
if config.MaxTokens != 8192 {
t.Errorf("expected MaxTokens unchanged with zero output limit, got %d", config.MaxTokens)
}
}
+170
View File
@@ -0,0 +1,170 @@
package models
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"charm.land/fantasy/providers/google"
)
// templatePlaceholderRe matches "${NAME}" placeholders in URL templates from
// models.dev (e.g. "https://${DATABRICKS_HOST}/ai-gateway/mlflow/v1").
var templatePlaceholderRe = regexp.MustCompile(`\$\{([A-Z0-9_]+)\}`)
// templateEnvVarOverrides supplies fallback environment variable names for
// placeholders that providers commonly use under non-obvious env names.
// The placeholder name itself is always tried first; this map adds extra
// names to try when the placeholder doesn't match the canonical env var.
var templateEnvVarOverrides = map[string][]string{
"CLOUDFLARE_ACCOUNT_ID": {"CF_ACCOUNT_ID"},
"CLOUDFLARE_GATEWAY_NAME": {"CF_GATEWAY", "CLOUDFLARE_GATEWAY"},
"DATABRICKS_HOST": {"DATABRICKS_WORKSPACE_URL"},
"SNOWFLAKE_ACCOUNT": {"SNOWFLAKE_ACCOUNT_ID"},
}
// resolveTemplatedAPIURL substitutes "${VAR}" placeholders in apiURL with the
// values of the named environment variables. Returns:
// - ("", nil) when apiURL contains no placeholders (caller keeps current URL),
// - (resolved, nil) when every placeholder was resolved,
// - ("", error) when one or more placeholders are unset, with a message that
// names the missing env vars and points at the relevant provider.
//
// The info parameter is used purely for error messaging (provider name).
func resolveTemplatedAPIURL(apiURL string, info *ProviderInfo) (string, error) {
if apiURL == "" || !strings.Contains(apiURL, "${") {
return "", nil
}
var missing []string
resolved := templatePlaceholderRe.ReplaceAllStringFunc(apiURL, func(match string) string {
// match is "${NAME}". Extract NAME.
name := match[2 : len(match)-1]
if v := os.Getenv(name); v != "" {
return v
}
for _, alt := range templateEnvVarOverrides[name] {
if v := os.Getenv(alt); v != "" {
return v
}
}
missing = append(missing, name)
return match
})
if len(missing) > 0 {
providerName := info.ID
if info.Name != "" {
providerName = info.Name
}
return "", fmt.Errorf(
"provider %s requires environment variable(s) %s to construct its API URL (%s); "+
"set them or pass --provider-url to override",
providerName, strings.Join(missing, ", "), apiURL,
)
}
return resolved, nil
}
// ResolveProviderBaseURL returns the base API URL kit will use when talking to
// the given provider, applying the same resolution order as CreateProvider:
//
// 1. The provider's `api` field from the models.dev registry.
// 2. The hard-coded default base URL of its npm SDK package (e.g.
// @ai-sdk/groq → https://api.groq.com/openai/v1).
// 3. Template substitution against the current process environment when the
// URL contains "${VAR}" placeholders (e.g. cloudflare-workers-ai needs
// CLOUDFLARE_ACCOUNT_ID).
//
// It returns an error when the provider is unknown, when no URL can be derived,
// or when a templated URL has unset placeholders. The error message is suitable
// for direct display to end users.
//
// Note: providers handled by bespoke auth schemes (amazon-bedrock SigV4,
// azure resource URLs, google-vertex project/location, sap-ai-core customer
// deployments) may return either an empty URL or a regional/templated URL —
// the actual endpoint is finalised inside their native handlers and depends on
// runtime credentials.
func ResolveProviderBaseURL(providerID string) (string, error) {
registry := GetGlobalRegistry()
info := registry.GetProviderInfo(providerID)
if info == nil {
return "", fmt.Errorf("unknown provider: %s", providerID)
}
apiURL := info.API
if apiURL == "" {
if defaultURL, ok := sdkDefaultBaseURL[info.NPM]; ok {
apiURL = defaultURL
}
}
if apiURL == "" {
return "", fmt.Errorf(
"provider %s has no default API URL: its npm package %q does not "+
"ship a built-in baseURL (likely Bedrock SigV4, Azure deployment, "+
"Vertex project/location, or a customer-hosted endpoint). "+
"Pass --provider-url or set the provider's URL env var",
providerID, info.NPM,
)
}
if strings.Contains(apiURL, "${") {
resolved, err := resolveTemplatedAPIURL(apiURL, info)
if err != nil {
return apiURL, err
}
return resolved, nil
}
return apiURL, nil
}
// createGoogleVertexProvider creates a Google Gemini provider that targets the
// Vertex AI backend (rather than the public generativelanguage.googleapis.com
// endpoint). It requires the same project/region environment variables as
// google-vertex-anthropic.
func createGoogleVertexProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
projectID := firstNonEmpty(
os.Getenv("GOOGLE_VERTEX_PROJECT"),
os.Getenv("GOOGLE_CLOUD_PROJECT"),
os.Getenv("GCLOUD_PROJECT"),
os.Getenv("CLOUDSDK_CORE_PROJECT"),
)
if projectID == "" {
return nil, fmt.Errorf(
"google Vertex project ID not provided, set GOOGLE_VERTEX_PROJECT, " +
"GOOGLE_CLOUD_PROJECT, or GCLOUD_PROJECT environment variable",
)
}
region := firstNonEmpty(
os.Getenv("GOOGLE_VERTEX_LOCATION"),
os.Getenv("CLOUD_ML_REGION"),
)
if region == "" {
region = "global"
}
opts := []google.Option{
google.WithVertex(projectID, region),
google.WithName("google-vertex"),
}
if config.TLSSkipVerify {
opts = append(opts, google.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
}
provider, err := google.New(opts...)
if err != nil {
return nil, wrapProviderErr("Google Vertex", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, wrapProviderErr("Google Vertex", "model", err)
}
return &ProviderResult{Model: model}, nil
}
+214
View File
@@ -0,0 +1,214 @@
package models
import (
"context"
"reflect"
"strings"
"testing"
)
// TestSDKDefaultBaseURL_CoversAllWireMappedPackages enforces the invariant
// that every npm package recognised by the auto-router has a corresponding
// default base URL — otherwise a provider that omits its `api` field in the
// registry would silently fail to route at runtime.
func TestSDKDefaultBaseURL_CoversAllWireMappedPackages(t *testing.T) {
for npm := range npmToWireProtocol {
// @ai-sdk/openai-compatible is a wire family, not a single SDK with
// a default URL — providers using it always supply their own `api`.
if npm == "@ai-sdk/openai-compatible" {
continue
}
if _, ok := sdkDefaultBaseURL[npm]; !ok {
t.Errorf("npm %q is in npmToWireProtocol but has no sdkDefaultBaseURL entry — "+
"providers using this npm with no `api` field cannot be routed", npm)
}
}
}
// TestSDKDefaultBaseURL_AllURLsAreAbsolute sanity-checks that every default
// URL is a well-formed absolute https endpoint (catches typos in the table).
func TestSDKDefaultBaseURL_AllURLsAreAbsolute(t *testing.T) {
for npm, url := range sdkDefaultBaseURL {
if !strings.HasPrefix(url, "https://") {
t.Errorf("sdkDefaultBaseURL[%q] = %q is not an absolute https URL", npm, url)
}
}
}
// TestResolveProviderBaseURL_RegistryFirst verifies that the registry's `api`
// field wins over any SDK default.
func TestResolveProviderBaseURL_RegistryFirst(t *testing.T) {
// xai is in the registry with no `api` field — its URL comes from the
// SDK default. Use a synthetic registry-backed provider to test the
// priority via the public registry instead.
url, err := ResolveProviderBaseURL("openai")
if err != nil {
t.Fatalf("ResolveProviderBaseURL(openai): %v", err)
}
if url != "https://api.openai.com/v1" {
t.Errorf("openai URL = %q, want https://api.openai.com/v1", url)
}
}
// TestResolveProviderBaseURL_SDKDefaultFallback verifies that providers
// without an `api` field (groq, cerebras, xai, …) resolve to their SDK
// hard-coded default URL.
func TestResolveProviderBaseURL_SDKDefaultFallback(t *testing.T) {
tests := map[string]string{
"groq": "https://api.groq.com/openai/v1",
"cerebras": "https://api.cerebras.ai/v1",
"xai": "https://api.x.ai/v1",
"mistral": "https://api.mistral.ai/v1",
"perplexity": "https://api.perplexity.ai",
"togetherai": "https://api.together.xyz/v1",
"deepinfra": "https://api.deepinfra.com/v1/openai",
"cohere": "https://api.cohere.com/compatibility/v1",
"v0": "https://api.v0.dev/v1",
"aihubmix": "https://aihubmix.com/v1",
"venice": "https://api.venice.ai/api/v1",
"openrouter": "https://openrouter.ai/api/v1",
}
for providerID, wantURL := range tests {
t.Run(providerID, func(t *testing.T) {
got, err := ResolveProviderBaseURL(providerID)
if err != nil {
t.Fatalf("ResolveProviderBaseURL(%s): %v", providerID, err)
}
if got != wantURL {
t.Errorf("%s URL = %q, want %q", providerID, got, wantURL)
}
})
}
}
// TestResolveProviderBaseURL_TemplatedURL_MissingEnv verifies that providers
// whose URL contains "${VAR}" placeholders surface a targeted error when the
// environment variables are unset.
func TestResolveProviderBaseURL_TemplatedURL_MissingEnv(t *testing.T) {
// cloudflare-workers-ai's api URL contains ${CLOUDFLARE_ACCOUNT_ID}.
// Ensure the variable is unset for this test.
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
t.Setenv("CF_ACCOUNT_ID", "")
_, err := ResolveProviderBaseURL("cloudflare-workers-ai")
if err == nil {
t.Fatal("expected error for unset CLOUDFLARE_ACCOUNT_ID, got nil")
}
if !strings.Contains(err.Error(), "CLOUDFLARE_ACCOUNT_ID") {
t.Errorf("error should name the missing env var, got: %v", err)
}
if !strings.Contains(err.Error(), "--provider-url") {
t.Errorf("error should suggest --provider-url override, got: %v", err)
}
}
// TestResolveProviderBaseURL_TemplatedURL_Resolved verifies env-var
// substitution succeeds when the placeholder is set.
func TestResolveProviderBaseURL_TemplatedURL_Resolved(t *testing.T) {
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-acct-123")
got, err := ResolveProviderBaseURL("cloudflare-workers-ai")
if err != nil {
t.Fatalf("ResolveProviderBaseURL: %v", err)
}
if !strings.Contains(got, "test-acct-123") {
t.Errorf("resolved URL %q should contain test-acct-123", got)
}
if strings.Contains(got, "${") {
t.Errorf("resolved URL %q still contains template placeholder", got)
}
}
// TestResolveProviderBaseURL_UnknownProvider verifies the not-in-registry error.
func TestResolveProviderBaseURL_UnknownProvider(t *testing.T) {
_, err := ResolveProviderBaseURL("does-not-exist")
if err == nil {
t.Fatal("expected error for unknown provider, got nil")
}
if !strings.Contains(err.Error(), "unknown provider") {
t.Errorf("error should say 'unknown provider', got: %v", err)
}
}
// TestAutoRouteProvider_SDKDefaultURLFallback verifies that providers whose
// registry entry omits the `api` field (groq, mistral, xai, etc.) are still
// auto-routed by falling back to the SDK's hard-coded default URL.
func TestAutoRouteProvider_SDKDefaultURLFallback(t *testing.T) {
tests := []struct {
name string
npmPackage string
wantInURL string
}{
{"groq", "@ai-sdk/groq", "groq.com"},
{"cerebras", "@ai-sdk/cerebras", "cerebras.ai"},
{"xai", "@ai-sdk/xai", "x.ai"},
{"mistral", "@ai-sdk/mistral", "mistral.ai"},
{"v0", "@ai-sdk/vercel", "v0.dev"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &ModelsRegistry{
providers: map[string]ProviderInfo{
"testfallback": {
ID: "testfallback",
Name: "Test Fallback",
Env: []string{"TESTFALLBACK_API_KEY"},
NPM: tt.npmPackage,
// API intentionally omitted — must fall back to SDK default.
Models: map[string]ModelInfo{
"any-model": {ID: "any-model", Name: "any-model"},
},
},
},
}
config := &ProviderConfig{ProviderAPIKey: "test-key"}
result, err := autoRouteProvider(context.Background(), config, "testfallback", "any-model", reg)
if err != nil {
t.Fatalf("autoRouteProvider returned error: %v", err)
}
if result == nil || result.Model == nil {
t.Fatal("autoRouteProvider returned nil model")
}
// Verify the SDK default URL was picked up.
if !strings.Contains(config.ProviderURL, tt.wantInURL) {
t.Errorf("config.ProviderURL = %q, want substring %q (SDK default)",
config.ProviderURL, tt.wantInURL)
}
// All these wrappers route through the openai-compat wire.
gotType := reflect.TypeOf(result.Model).String()
if gotType != "openai.languageModel" {
t.Errorf("model type = %q, want openai.languageModel", gotType)
}
})
}
}
// TestResolveTemplatedAPIURL_NoPlaceholders verifies that URLs without
// placeholders are returned as-is (the caller keeps using the original).
func TestResolveTemplatedAPIURL_NoPlaceholders(t *testing.T) {
got, err := resolveTemplatedAPIURL("https://api.example.com/v1", &ProviderInfo{ID: "x"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("got %q, want empty string for URL with no placeholders", got)
}
}
// TestResolveTemplatedAPIURL_AltEnvVar verifies that the alternative env-var
// names (e.g. CF_ACCOUNT_ID for CLOUDFLARE_ACCOUNT_ID) are honoured.
func TestResolveTemplatedAPIURL_AltEnvVar(t *testing.T) {
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
t.Setenv("CF_ACCOUNT_ID", "alt-name-123")
got, err := resolveTemplatedAPIURL(
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
&ProviderInfo{ID: "cloudflare-workers-ai"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(got, "alt-name-123") {
t.Errorf("resolved URL %q should have picked up CF_ACCOUNT_ID alternative", got)
}
}
+39 -35
View File
@@ -36,15 +36,17 @@ type Diagnostic struct {
}
// LoadAll discovers and loads all prompt templates from standard locations
// and any extra paths. Templates are loaded in order of precedence (lowest
// to highest), with later templates overriding earlier ones of the same name.
// and any extra paths. Templates are loaded in order of precedence (highest
// to lowest); the first source to define a given name wins, later definitions
// of the same name are dropped with a diagnostic.
//
// Discovery paths searched in order:
// 1. Default templates (if IncludeDefaults)
// 2. ~/.kit/prompts/ (global user templates)
// 3. .kit/prompts/ (project-local templates)
// 4. ConfigPaths (from configuration)
// 5. ExtraPaths (explicit paths, highest precedence)
// 2. ~/.kit/prompts/ (legacy global)
// 3. $XDG_CONFIG_HOME/kit/prompts/ (XDG global, default ~/.config/kit/prompts/)
// 4. <cwd>/.kit/prompts/ (project-local templates)
// 5. ConfigPaths (from configuration)
// 6. ExtraPaths (explicit paths, lowest precedence)
func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) {
if opts.Cwd == "" {
opts.Cwd, _ = os.Getwd()
@@ -88,13 +90,21 @@ func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) {
addTemplates(defaults, "default")
}
// 2. Global user templates: ~/.kit/prompts/
globalDir := filepath.Join(opts.HomeDir, ".kit", "prompts")
if templates, err := LoadFromDir(globalDir); err == nil {
// 2. Legacy global user templates: ~/.kit/prompts/
legacyGlobalDir := filepath.Join(opts.HomeDir, ".kit", "prompts")
if templates, err := LoadFromDir(legacyGlobalDir); err == nil {
addTemplates(templates, "global")
}
// 3. Project-local templates: .kit/prompts/
// 3. XDG global user templates: $XDG_CONFIG_HOME/kit/prompts/
// Default: ~/.config/kit/prompts/. Aligns with extensions and skills.
if xdgDir := GlobalDir(); xdgDir != "" && xdgDir != legacyGlobalDir {
if templates, err := LoadFromDir(xdgDir); err == nil {
addTemplates(templates, "global")
}
}
// 4. Project-local templates: .kit/prompts/
localDir := filepath.Join(opts.Cwd, ".kit", "prompts")
if templates, err := LoadFromDir(localDir); err == nil {
addTemplates(templates, "local")
@@ -179,31 +189,6 @@ func LoadFromDir(dir string) ([]*PromptTemplate, error) {
return templates, nil
}
// Deduplicate removes duplicate templates by name, keeping the first occurrence.
// It returns the deduplicated list and diagnostics for any collisions.
// This is a standalone function for when you need to deduplicate an existing list.
func Deduplicate(templates []*PromptTemplate) ([]*PromptTemplate, []Diagnostic) {
seen := make(map[string]*PromptTemplate)
var result []*PromptTemplate
var diagnostics []Diagnostic
for _, tpl := range templates {
if existing, ok := seen[tpl.Name]; ok {
diagnostics = append(diagnostics, Diagnostic{
Name: tpl.Name,
KeptPath: existing.FilePath,
DroppedPath: tpl.FilePath,
Reason: "duplicate template name (first-match-wins)",
})
} else {
seen[tpl.Name] = tpl
result = append(result, tpl)
}
}
return result, diagnostics
}
// loadDefaultTemplates returns the built-in default templates.
// These are embedded templates that ship with Kit.
func loadDefaultTemplates() []*PromptTemplate {
@@ -211,3 +196,22 @@ func loadDefaultTemplates() []*PromptTemplate {
// For now, return an empty slice - users can define their own templates
return nil
}
// GlobalDir returns the XDG-aligned global prompts directory, respecting
// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/prompts/. Returns an empty
// string if the user's home directory cannot be resolved.
//
// This is the canonical location for user-wide prompt templates and aligns
// with the discovery paths used for extensions ($XDG_CONFIG_HOME/kit/extensions/)
// and skills ($XDG_CONFIG_HOME/kit/skills/).
func GlobalDir() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".config")
}
return filepath.Join(base, "kit", "prompts")
}
+4 -9
View File
@@ -70,7 +70,8 @@ func ParseTemplate(path string) (*PromptTemplate, error) {
}
// ParseCommandArgs splits a command line into arguments respecting quotes.
// It handles single quotes, double quotes, and backslash escaping.
// It handles single quotes, double quotes, backslash escaping, and splits on
// spaces and tabs.
func ParseCommandArgs(input string) []string {
var args []string
var current strings.Builder
@@ -78,7 +79,7 @@ func ParseCommandArgs(input string) []string {
inDoubleQuote := false
escaped := false
for i, r := range input {
for _, r := range input {
if escaped {
current.WriteRune(r)
escaped = false
@@ -101,7 +102,7 @@ func ParseCommandArgs(input string) []string {
continue
}
if r == ' ' && !inSingleQuote && !inDoubleQuote {
if (r == ' ' || r == '\t') && !inSingleQuote && !inDoubleQuote {
if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
@@ -110,7 +111,6 @@ func ParseCommandArgs(input string) []string {
}
current.WriteRune(r)
_ = i // silence unused warning when we need position later
}
if current.Len() > 0 {
@@ -325,8 +325,3 @@ func (t *PromptTemplate) Expand(argsInput string) string {
args := ParseCommandArgs(argsInput)
return SubstituteArgs(t.Content, args)
}
// ExpandWithArgs substitutes the provided arguments into the template content.
func (t *PromptTemplate) ExpandWithArgs(args []string) string {
return SubstituteArgs(t.Content, args)
}
+66
View File
@@ -0,0 +1,66 @@
package session
import (
"testing"
"github.com/mark3labs/kit/internal/message"
)
// TestCompactionParentCycleRegression tests that after multiple compactions,
// newly appended messages always have a valid parent chain and BuildContext
// returns the correct messages.
func TestCompactionParentCycleRegression(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Simulate a long conversation with multiple compactions.
msg1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
msg2, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
// First compaction
comp1, _ := tm.AppendCompaction("Summary 1", msg1, 1000, 500, 1, []string{}, []string{})
msg3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
msg4, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg4"}}})
// Second compaction
comp2, _ := tm.AppendCompaction("Summary 2", msg3, 1000, 500, 1, []string{}, []string{})
msg5, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg5"}}})
msg6, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg6"}}})
// Verify parent chain integrity
for _, id := range []string{msg1, msg2, comp1, msg3, msg4, comp2, msg5, msg6} {
entry := tm.GetEntry(id)
if entry == nil {
t.Fatalf("entry %s not found in index", id)
}
}
// Walk parent chain from msg6 — must reach root without cycles
visited := make(map[string]bool)
current := msg6
for current != "" {
if visited[current] {
t.Fatalf("cycle detected at entry %s", current)
}
visited[current] = true
entry := tm.GetEntry(current)
if entry == nil {
t.Fatalf("entry %s missing from index during parent walk", current)
}
parent := ""
switch e := entry.(type) {
case *MessageEntry:
parent = e.ParentID
case *CompactionEntry:
parent = e.ParentID
}
current = parent
}
// BuildContext should return: Summary2 + msg6 + msg5 + msg3 + msg4 = 5 messages
msgs, _, _ := tm.BuildContext()
if len(msgs) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
}
}
+18 -9
View File
@@ -129,26 +129,35 @@ func TestCompactionWithNewMessagesAfterCompaction(t *testing.T) {
msg4 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Message 4 - after compaction"}}}
_, _ = tm.AppendMessage(msg4)
// BuildContext should return: [summary] + [M4 (new after compaction)] + [M3 (kept)]
// BuildContext should return: [summary] + [M3 (kept)] + [M4 (new after compaction)]
// Kept messages must appear BEFORE post-compaction messages so the LLM
// sees the conversation in chronological order. Otherwise the latest
// post-compaction user message would be followed by an older kept user
// message, breaking user/assistant alternation and causing the model to
// respond as if the post-compaction turn never happened.
messages, _, _ := tm.BuildContext()
if len(messages) != 3 {
t.Fatalf("expected 3 messages (summary + M4 + M3), got %d: %+v", len(messages), messages)
t.Fatalf("expected 3 messages (summary + M3 + M4), got %d: %+v", len(messages), messages)
}
// Verify order: summary, M4 (new), M3 (kept)
// Verify order: summary, M3 (kept), M4 (new)
if messages[0].Role != fantasy.MessageRoleSystem {
t.Errorf("first message should be summary, got %s", messages[0].Role)
}
if messages[1].Role != fantasy.MessageRoleAssistant {
t.Errorf("second message should be assistant (M4), got %s", messages[1].Role)
if messages[1].Role != fantasy.MessageRoleUser {
t.Errorf("second message should be user (M3 kept), got %s", messages[1].Role)
}
m4Text := messages[1].Content[0].(fantasy.TextPart).Text
m3Text := messages[1].Content[0].(fantasy.TextPart).Text
if m3Text != "Message 3 - kept" {
t.Errorf("unexpected M3 text: %s", m3Text)
}
if messages[2].Role != fantasy.MessageRoleAssistant {
t.Errorf("third message should be assistant (M4 post-compact), got %s", messages[2].Role)
}
m4Text := messages[2].Content[0].(fantasy.TextPart).Text
if m4Text != "Message 4 - after compaction" {
t.Errorf("unexpected M4 text: %s", m4Text)
}
if messages[2].Role != fantasy.MessageRoleUser {
t.Errorf("third message should be user (M3), got %s", messages[2].Role)
}
// Verify that M1 is NOT in the context
for i, msg := range messages {
+70
View File
@@ -0,0 +1,70 @@
package session
import (
"strings"
"testing"
)
// TestEncodeCwdForDir verifies the working-directory → session-directory
// name encoding strips characters that are illegal on Windows (notably the
// drive-letter colon, see issue #18) while preserving the previous output
// for the typical Unix paths.
func TestEncodeCwdForDir(t *testing.T) {
tests := []struct {
name string
cwd string
want string
}{
{
name: "unix absolute path",
cwd: "/home/user/proj",
want: "home--user--proj",
},
{
name: "unix relative path",
cwd: "proj/sub",
want: "proj--sub",
},
{
name: "windows drive root",
cwd: `C:\test`,
want: "C--test",
},
{
name: "windows nested path",
cwd: `C:\Users\User\code`,
want: "C--Users--User--code",
},
{
name: "windows secondary drive",
cwd: `S:\work\repo`,
want: "S--work--repo",
},
{
name: "windows mixed separators",
cwd: `C:\Users/User\code`,
want: "C--Users--User--code",
},
{
name: "windows other illegal chars stripped",
cwd: `C:\a<b>c|d?e*f"g`,
want: "C--abcdefg",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := encodeCwdForDir(tc.cwd)
if got != tc.want {
t.Errorf("encodeCwdForDir(%q) = %q, want %q", tc.cwd, got, tc.want)
}
// Encoded directory must never contain characters that are
// illegal in Windows directory names.
for _, bad := range []string{":", "<", ">", "\"", "|", "?", "*", "\\", "/"} {
if strings.Contains(got, bad) {
t.Errorf("encodeCwdForDir(%q) = %q contains illegal char %q", tc.cwd, got, bad)
}
}
})
}
}
+41 -7
View File
@@ -6,8 +6,10 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
)
@@ -97,6 +99,11 @@ func ListAllSessions() ([]SessionInfo, error) {
// listSessionsInDir reads all .jsonl files in a directory and extracts session info.
// Empty sessions (no messages) are automatically cleaned up and not returned.
//
// Per-file extraction is parallelized across a small worker pool because each
// file requires a full JSONL scan to compute MessageCount and FirstMessage —
// for users with many sessions this is the dominant cost of opening the
// session picker.
func listSessionsInDir(dir string) ([]SessionInfo, error) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return nil, nil
@@ -107,20 +114,47 @@ func listSessionsInDir(dir string) ([]SessionInfo, error) {
return nil, fmt.Errorf("failed to read directory %s: %w", dir, err)
}
var sessions []SessionInfo
// Collect candidate paths first so we can parallelize the heavy work.
paths := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
paths = append(paths, filepath.Join(dir, entry.Name()))
}
path := filepath.Join(dir, entry.Name())
info, err := extractSessionInfo(path)
if err != nil {
continue // skip malformed session files
results := make([]*SessionInfo, len(paths))
// Worker pool sized to GOMAXPROCS, capped to avoid thrashing for tiny lists.
workers := max(min(runtime.GOMAXPROCS(0), len(paths)), 1)
var wg sync.WaitGroup
jobs := make(chan int, len(paths))
for range workers {
wg.Go(func() {
for i := range jobs {
info, err := extractSessionInfo(paths[i])
if err != nil {
continue // skip malformed session files
}
results[i] = info
}
})
}
for i := range paths {
jobs <- i
}
close(jobs)
wg.Wait()
sessions := make([]SessionInfo, 0, len(results))
for i, info := range results {
if info == nil {
continue
}
// Clean up and skip empty sessions (no messages)
// Clean up and skip empty sessions (no messages).
if info.MessageCount == 0 {
_ = os.Remove(path)
_ = os.Remove(paths[i])
continue
}
sessions = append(sessions, *info)
+109
View File
@@ -0,0 +1,109 @@
package session
import (
"testing"
"github.com/mark3labs/kit/internal/message"
)
// TestDetectCycleWithCorruptedParentChain tests that cycle detection works
// when a corrupted session has circular parent references.
func TestDetectCycleWithCorruptedParentChain(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Create normal chain: msg1 -> msg2 -> msg3
id1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
_, _ = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
id3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
// Simulate corruption: manually set msg1's parent to msg3, creating cycle
// This simulates the condition seen in the user's session
for _, entry := range tm.entries {
if e, ok := entry.(*MessageEntry); ok && e.ID == id1 {
e.ParentID = id3 // Create cycle: msg1 -> msg3 -> ... -> msg1
break
}
}
// DetectCycle should find the cycle
// The cycle is: id1 -> id3 -> id2 -> id1
// So detecting from id3 should find id1 as the repeat
cycle, entry := tm.DetectCycle(id3)
if !cycle {
t.Fatal("expected to detect cycle, but none found")
}
// The cycle entry could be id1 or id3 depending on where we start
if entry != id1 && entry != id3 {
t.Fatalf("expected cycle at %s or %s, got %s", id1, id3, entry)
}
// BuildContext should still work (it has its own cycle detection)
// but will truncate at the cycle point
msgs, _, _ := tm.BuildContext()
if len(msgs) == 0 {
t.Fatal("BuildContext returned no messages")
}
}
// TestAppendMessageRejectsInvalidParent tests that AppendMessage rejects
// appending when the current leaf has a broken parent chain.
func TestAppendMessageRejectsInvalidParent(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Create normal message
id1, err := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
if err != nil {
t.Fatalf("failed to append msg1: %v", err)
}
// Simulate corruption: set leafID to a non-existent ID
tm.leafID = "non-existent-id"
// Next append should fail validation
_, err = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
if err == nil {
t.Fatal("expected error when appending with invalid leafID, got nil")
}
// Restore valid leafID
tm.leafID = id1
// Append should succeed now
_, err = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
if err != nil {
t.Fatalf("failed to append msg3 after restoring leafID: %v", err)
}
}
// TestBuildContextHandlesCycleGracefully tests that BuildContext handles
// cycles gracefully by truncating the branch.
func TestBuildContextHandlesCycleGracefully(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Create messages
id1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
_, _ = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
id3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
// Verify normal case works
msgs, _, _ := tm.BuildContext()
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
// Simulate cycle: set msg1's parent to msg3
for _, entry := range tm.entries {
if e, ok := entry.(*MessageEntry); ok && e.ID == id1 {
e.ParentID = id3
break
}
}
// BuildContext should handle cycle gracefully (getBranchLocked has cycle detection)
msgs, _, _ = tm.BuildContext()
// Should only include messages from the cycle: msg3, msg2, msg1
// (msg3 is leaf, walks to msg2 -> msg1 -> msg3 (cycle detected, stops))
if len(msgs) != 3 {
t.Fatalf("expected 3 messages in cycle case, got %d: %+v", len(msgs), msgs)
}
}
+232 -103
View File
@@ -63,6 +63,11 @@ type TreeManager struct {
// file is the open file handle for appending entries. Nil for in-memory.
file *os.File
// writer is a buffered writer wrapping file. Writes go through this
// buffer and are flushed to disk at explicit sync points (after each
// public Append* call, in Close, etc.) to reduce syscall overhead.
writer *bufio.Writer
}
// --- Constructors ---
@@ -105,11 +110,16 @@ func CreateTreeSession(cwd string) (*TreeManager, error) {
return nil, fmt.Errorf("failed to create session file: %w", err)
}
tm.file = f
tm.writer = bufio.NewWriter(f)
if err := tm.writeEntry(&header); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to write session header: %w", err)
}
if err := tm.flushLocked(); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to flush session header: %w", err)
}
return tm, nil
}
@@ -150,6 +160,7 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag
return nil, fmt.Errorf("failed to recreate session file: %w", err)
}
newTm.file = f
newTm.writer = bufio.NewWriter(f)
if err := newTm.writeEntry(&newTm.header); err != nil {
_ = f.Close()
@@ -289,6 +300,12 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag
}
}
// Flush all buffered writes from the fork in a single syscall.
if err := newTm.flushLocked(); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to flush forked session: %w", err)
}
// Set the leaf to the last entry in the new session.
newTm.leafID = prevNewID
@@ -365,12 +382,16 @@ func OpenTreeSession(path string) (*TreeManager, error) {
tm.leafID = tm.EntryID(tm.entries[len(tm.entries)-1])
}
// Validate tree integrity and log diagnostics
tm.LogTreeDiagnostics()
// Open file for appending.
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open session file for append: %w", err)
}
tm.file = f
tm.writer = bufio.NewWriter(f)
return tm, nil
}
@@ -410,6 +431,12 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
tm.mu.Lock()
defer tm.mu.Unlock()
// Validate parent chain before appending to detect/prevent cycles
// that could be caused by external file corruption or race conditions.
if err := tm.validateParentChainLocked(tm.leafID, ""); err != nil {
return "", fmt.Errorf("parent chain validation failed: %w", err)
}
entry, err := NewMessageEntry(tm.leafID, msg)
if err != nil {
return "", err
@@ -418,6 +445,9 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush message: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -428,11 +458,6 @@ func (tm *TreeManager) AppendLLMMessage(msg fantasy.Message) (string, error) {
return tm.AppendMessage(message.FromLLMMessage(msg))
}
// Deprecated: Use AppendLLMMessage instead.
func (tm *TreeManager) AppendFantasyMessage(msg fantasy.Message) (string, error) {
return tm.AppendLLMMessage(msg)
}
// AppendModelChange records a model/provider change.
func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, error) {
tm.mu.Lock()
@@ -442,6 +467,9 @@ func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, erro
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush model change: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -456,6 +484,9 @@ func (tm *TreeManager) AppendBranchSummary(fromID, summary string) (string, erro
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush branch summary: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -470,6 +501,9 @@ func (tm *TreeManager) AppendLabel(targetID, label string) (string, error) {
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush label: %w", err)
}
tm.labels[targetID] = label
tm.leafID = entry.ID
@@ -485,6 +519,9 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush session info: %w", err)
}
tm.sessionName = name
tm.leafID = entry.ID
@@ -501,6 +538,9 @@ func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error)
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush extension data: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -518,6 +558,13 @@ func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokens
tm.mu.Lock()
defer tm.mu.Unlock()
// Validate that firstKeptEntryID exists if provided
if firstKeptEntryID != "" {
if _, ok := tm.index[firstKeptEntryID]; !ok {
return "", fmt.Errorf("first kept entry %q does not exist", firstKeptEntryID)
}
}
// The compaction entry has no parent, making it a new "root" for the
// post-compaction branch. This ensures old compacted messages are not
// traversed when walking from the current leaf.
@@ -525,6 +572,9 @@ func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokens
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush compaction: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -700,9 +750,17 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
}
}
// If there is a compaction, inject the summary first and collect
// the kept messages starting from FirstKeptEntryID (since the
// compaction entry's parent chain doesn't include them).
// If there is a compaction, inject the summary first, then the
// preserved "kept" messages (chronologically before the compaction),
// then the post-compaction messages (chronologically after).
//
// Order matters: the kept messages must come BEFORE the post-compaction
// branch so the LLM sees the conversation in chronological order. If the
// kept messages were appended last, the latest user message in the
// current branch would be followed by an older kept user message,
// breaking the strict user/assistant alternation that providers expect
// and causing the model to respond as if the previous turn never
// happened.
if lastCompaction != nil {
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRoleSystem,
@@ -713,49 +771,10 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
},
})
// Collect entries from the compaction entry itself (at compactionIndex)
// and any entries before it in the branch (newer messages).
for i := compactionIndex; i < len(branch); i++ {
entry := branch[i]
switch e := entry.(type) {
case *MessageEntry:
msg, err := e.ToMessage()
if err != nil {
continue // skip malformed entries
}
msgs := msg.ToLLMMessages()
messages = append(messages, msgs...)
case *BranchSummaryEntry:
// Convert branch summary to a user message for context.
if e.Summary != "" {
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{
Text: fmt.Sprintf("[Branch context: %s]", e.Summary),
},
},
})
}
case *ModelChangeEntry:
provider = e.Provider
modelID = e.ModelID
case *CompactionEntry:
// Already handled above (summary injected).
continue
}
}
// Now collect the kept messages starting from FirstKeptEntryID.
// These are not in the current branch because the compaction entry
// is parented to the first kept entry's parent, not the first kept entry.
// We iterate through entries in order (not using getBranchLocked) to avoid
// walking back to old compacted messages.
// We stop when we reach the compaction entry to avoid double-counting
// messages that were added after the compaction.
// Step 1: collect the kept messages starting from FirstKeptEntryID.
// These are not on the current branch (the compaction entry is a
// new root with no parent), so we iterate tm.entries in append order
// and stop when we reach the compaction entry itself.
if lastCompaction.FirstKeptEntryID != "" {
found := false
for _, entry := range tm.entries {
@@ -770,13 +789,12 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
}
}
// Stop when we reach the compaction entry itself.
// Messages after the compaction are collected from the branch walk above.
// Stop when we reach the compaction entry itself; messages
// after it are collected from the branch walk below.
if entryID == lastCompaction.ID {
break
}
// Process this kept entry.
switch e := entry.(type) {
case *MessageEntry:
msg, err := e.ToMessage()
@@ -805,6 +823,42 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
}
}
// Step 2: collect entries on the current branch after the compaction
// entry (these are post-compaction messages). The compaction entry
// itself is skipped — its summary was already injected above.
for i := compactionIndex; i < len(branch); i++ {
entry := branch[i]
switch e := entry.(type) {
case *MessageEntry:
msg, err := e.ToMessage()
if err != nil {
continue
}
msgs := msg.ToLLMMessages()
messages = append(messages, msgs...)
case *BranchSummaryEntry:
if e.Summary != "" {
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{
Text: fmt.Sprintf("[Branch context: %s]", e.Summary),
},
},
})
}
case *ModelChangeEntry:
provider = e.Provider
modelID = e.ModelID
case *CompactionEntry:
// Summary already injected above.
continue
}
}
return messages, provider, modelID
}
@@ -910,11 +964,31 @@ func (tm *TreeManager) IsEmpty() bool {
return tm.MessageCount() == 0
}
// Close closes the underlying file handle.
// Flush writes any buffered data to the underlying file.
func (tm *TreeManager) Flush() error {
tm.mu.Lock()
defer tm.mu.Unlock()
return tm.flushLocked()
}
// flushLocked writes buffered data to disk. Caller must hold the lock.
func (tm *TreeManager) flushLocked() error {
if tm.writer != nil {
return tm.writer.Flush()
}
return nil
}
// Close flushes any buffered writes and closes the underlying file handle.
func (tm *TreeManager) Close() error {
tm.mu.Lock()
defer tm.mu.Unlock()
if tm.file != nil {
// Flush buffered data before closing.
if tm.writer != nil {
_ = tm.writer.Flush()
tm.writer = nil
}
err := tm.file.Close()
tm.file = nil
return err
@@ -955,44 +1029,22 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
var ids []string
// If there's a compaction, we need to collect IDs from:
// 1. Entries after the compaction entry in the branch (newer messages)
// 2. Entries from FirstKeptEntryID onwards (kept messages)
// If there's a compaction, we collect IDs in the same order as
// BuildContext: [summary placeholder, kept messages, post-compaction
// messages]. This ordering must stay in sync with BuildContext so a
// cut-point index can be mapped back to the correct entry ID.
if lastCompaction != nil {
// Placeholder for the summary system message (no entry ID).
ids = append(ids, "")
// Collect IDs from entries after the compaction entry (newer messages).
for i := compactionIndex + 1; i < len(branch); i++ {
entry := branch[i]
switch e := entry.(type) {
case *MessageEntry:
msg, err := e.ToMessage()
if err != nil {
continue
}
msgs := msg.ToLLMMessages()
for range msgs {
ids = append(ids, e.ID)
}
case *BranchSummaryEntry:
if e.Summary != "" {
ids = append(ids, e.ID)
}
}
}
// Collect IDs from the kept messages starting at FirstKeptEntryID.
// We iterate through entries in order (not using getBranchLocked) to avoid
// walking back to old compacted messages.
// We stop when we reach the compaction entry to avoid double-counting.
// Step 1: IDs of the kept messages starting at FirstKeptEntryID.
// Iterate tm.entries in append order and stop at the compaction
// entry to avoid double-counting post-compaction messages.
if lastCompaction.FirstKeptEntryID != "" {
found := false
for _, entry := range tm.entries {
entryID := tm.EntryID(entry)
// Skip entries until we reach the first kept entry.
if !found {
if entryID == lastCompaction.FirstKeptEntryID {
found = true
@@ -1001,7 +1053,6 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
}
}
// Stop when we reach the compaction entry itself.
if entryID == lastCompaction.ID {
break
}
@@ -1025,6 +1076,28 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
}
}
// Step 2: IDs of entries after the compaction entry on the current
// branch (post-compaction messages).
for i := compactionIndex + 1; i < len(branch); i++ {
entry := branch[i]
switch e := entry.(type) {
case *MessageEntry:
msg, err := e.ToMessage()
if err != nil {
continue
}
msgs := msg.ToLLMMessages()
for range msgs {
ids = append(ids, e.ID)
}
case *BranchSummaryEntry:
if e.Summary != "" {
ids = append(ids, e.ID)
}
}
}
return ids
}
@@ -1074,18 +1147,22 @@ func (tm *TreeManager) GetLastCompaction() *CompactionEntry {
// AddLLMMessages appends multiple LLM messages as entries. This is
// used when syncing from the agent's ConversationMessages after a step.
// All entries are buffered and flushed to disk in a single batch.
func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
tm.mu.Lock()
defer tm.mu.Unlock()
for _, msg := range msgs {
if _, err := tm.AppendLLMMessage(msg); err != nil {
entry, err := NewMessageEntry(tm.leafID, message.FromLLMMessage(msg))
if err != nil {
return err
}
if err := tm.appendAndPersist(entry); err != nil {
return err
}
tm.leafID = entry.ID
}
return nil
}
// Deprecated: Use AddLLMMessages instead.
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
return tm.AddLLMMessages(msgs)
return tm.flushLocked()
}
// GetLLMMessages builds the context and returns just the messages.
@@ -1095,11 +1172,6 @@ func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
return msgs
}
// Deprecated: Use GetLLMMessages instead.
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
return tm.GetLLMMessages()
}
// --- Internal helpers ---
// addEntryToIndex adds an entry to the in-memory indices.
@@ -1132,12 +1204,20 @@ func (tm *TreeManager) appendAndPersist(entry any) error {
return nil
}
// writeEntry serializes an entry and appends it as a line to the file.
// writeEntry serializes an entry and appends it to the buffered writer.
// The data is not flushed to disk until flushLocked is called.
func (tm *TreeManager) writeEntry(entry any) error {
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("failed to marshal entry: %w", err)
}
if tm.writer != nil {
if _, err := tm.writer.Write(data); err != nil {
return err
}
return tm.writer.WriteByte('\n')
}
// Fallback for direct file writes (shouldn't happen in normal flow).
data = append(data, '\n')
_, err = tm.file.Write(data)
return err
@@ -1213,12 +1293,32 @@ func (tm *TreeManager) getBranchLocked(fromID string) []any {
}
// buildTreeNode recursively builds a TreeNode from an entry ID.
// It includes a depth limit to prevent infinite recursion in case of
// corrupted parent-child relationships.
func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
return tm.buildTreeNodeDepth(id, 0, make(map[string]bool))
}
// buildTreeNodeDepth is the internal implementation with depth tracking.
func (tm *TreeManager) buildTreeNodeDepth(id string, depth int, visited map[string]bool) *TreeNode {
const maxDepth = 1000
if depth > maxDepth {
// Cycle or extremely deep tree detected, stop recursing
return nil
}
if visited[id] {
// Cycle detected, stop recursing
return nil
}
entry, ok := tm.index[id]
if !ok {
return nil
}
visited[id] = true
defer delete(visited, id)
node := &TreeNode{
Entry: entry,
ID: id,
@@ -1226,7 +1326,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
}
for _, childID := range tm.childIndex[id] {
child := tm.buildTreeNode(childID)
child := tm.buildTreeNodeDepth(childID, depth+1, visited)
if child != nil {
node.Children = append(node.Children, child)
}
@@ -1238,15 +1338,44 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
// --- Path conventions ---
// DefaultSessionDir returns the default session storage directory for a cwd.
// Convention: ~/.kit/sessions/--<cwd-path>--/
// Convention: ~/.kit/sessions/<encoded-cwd>, where path separators are
// encoded as "--" with no leading or trailing dashes — e.g.
// /home/user/proj becomes home--user--proj. See encodeCwdForDir for the
// full encoding rules (including Windows path handling).
func DefaultSessionDir(cwd string) string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
// Convert path separators to double dashes.
safeCwd := strings.ReplaceAll(cwd, string(filepath.Separator), "--")
return filepath.Join(home, ".kit", "sessions", encodeCwdForDir(cwd))
}
// encodeCwdForDir converts a working-directory path into a single, filesystem-
// safe directory name. Path separators are replaced with double dashes and
// characters that are illegal in Windows directory names — most importantly
// the colon that follows the drive letter (e.g. `C:\foo` → `C--foo`) — are
// stripped. The result is identical to the previous Unix-only encoding for
// paths that do not contain such characters, so existing session directories
// are preserved.
func encodeCwdForDir(cwd string) string {
// Convert both `/` and `\` to double dashes so encoding is stable across
// platforms and remains correct on Windows where `filepath.Separator`
// would otherwise miss forward-slash style paths.
safeCwd := strings.ReplaceAll(cwd, "\\", "--")
safeCwd = strings.ReplaceAll(safeCwd, "/", "--")
// Remove leading separator replacement.
safeCwd = strings.TrimPrefix(safeCwd, "--")
return filepath.Join(home, ".kit", "sessions", safeCwd)
// Strip characters that are illegal in directory names on Windows
// (`< > : " | ? *`). On Unix these characters are legal but rare in
// practice; stripping them keeps the encoding portable.
replacer := strings.NewReplacer(
":", "",
"<", "",
">", "",
"\"", "",
"|", "",
"?", "",
"*", "",
)
return replacer.Replace(safeCwd)
}
+143
View File
@@ -0,0 +1,143 @@
package session
import (
"fmt"
"log"
)
// ValidateParentChain checks that the parent ID points to an existing entry
// and that appending this entry would not create a cycle. This should be called
// before appending any entry to the tree.
// Returns an error if the parent is invalid or would create a cycle.
func (tm *TreeManager) ValidateParentChain(parentID string, newEntryID string) error {
if parentID == "" {
// Empty parent is valid (root entry)
return nil
}
// Check that parent exists
if _, ok := tm.index[parentID]; !ok {
return fmt.Errorf("parent entry %q does not exist in index", parentID)
}
// Check that we're not creating a cycle by walking up the parent chain
// from parentID and ensuring we don't hit newEntryID (or any node that
// has newEntryID as an ancestor, but since newEntryID is new, just check
// that parentID isn't newEntryID, which it can't be since we check existence)
visited := make(map[string]bool)
current := parentID
for current != "" {
if visited[current] {
return fmt.Errorf("existing cycle detected at entry %q", current)
}
visited[current] = true
// Safety check: if somehow we reach the new entry ID, that's a cycle
if current == newEntryID {
return fmt.Errorf("would create cycle: entry %q cannot be its own ancestor", newEntryID)
}
entry, ok := tm.index[current]
if !ok {
return fmt.Errorf("broken parent chain: entry %q not found", current)
}
current = tm.entryParentID(entry)
}
return nil
}
// DetectCycle walks the parent chain from the given entry ID and returns true
// if a cycle is detected. This is used for diagnostics.
func (tm *TreeManager) DetectCycle(fromID string) (cycleDetected bool, cycleEntry string) {
visited := make(map[string]bool)
current := fromID
for current != "" {
if visited[current] {
return true, current
}
visited[current] = true
entry, ok := tm.index[current]
if !ok {
return false, ""
}
current = tm.entryParentID(entry)
}
return false, ""
}
// LogTreeDiagnostics logs information about the tree structure for debugging.
// Call this after OpenTreeSession or when anomalies are detected.
func (tm *TreeManager) LogTreeDiagnostics() {
tm.mu.RLock()
defer tm.mu.RUnlock()
log.Printf("[TreeManager] Entry count: %d, Leaf ID: %s", len(tm.entries), tm.leafID)
// Check for cycles from leaf
if tm.leafID != "" {
if cycle, entry := tm.detectCycleLocked(tm.leafID); cycle {
log.Printf("[TreeManager] WARNING: Cycle detected in tree at entry %s", entry)
}
}
// Count entries by type
counts := make(map[EntryType]int)
for _, entry := range tm.entries {
var et EntryType
switch e := entry.(type) {
case *MessageEntry:
et = e.Type
case *ModelChangeEntry:
et = e.Type
case *BranchSummaryEntry:
et = e.Type
case *LabelEntry:
et = e.Type
case *SessionInfoEntry:
et = e.Type
case *ExtensionDataEntry:
et = e.Type
case *CompactionEntry:
et = e.Type
default:
et = "unknown"
}
counts[et]++
}
log.Printf("[TreeManager] Entry types: %+v", counts)
}
// detectCycleLocked is the internal version of DetectCycle (must hold read lock)
func (tm *TreeManager) detectCycleLocked(fromID string) (bool, string) {
visited := make(map[string]bool)
current := fromID
for current != "" {
if visited[current] {
return true, current
}
visited[current] = true
entry, ok := tm.index[current]
if !ok {
return false, ""
}
current = tm.entryParentID(entry)
}
return false, ""
}
// validateParentChainLocked is the internal version used by append methods.
// Must be called with the write lock held.
func (tm *TreeManager) validateParentChainLocked(parentID string, newEntryID string) error {
if parentID == "" {
return nil
}
if _, ok := tm.index[parentID]; !ok {
return fmt.Errorf("parent entry %q does not exist", parentID)
}
// Check for existing cycles in the parent chain
if cycle, entry := tm.detectCycleLocked(parentID); cycle {
return fmt.Errorf("existing cycle detected at entry %q in parent chain", entry)
}
return nil
}
+12 -7
View File
@@ -18,8 +18,11 @@ type PromptTemplate struct {
Variables []string
}
// variableRe matches {{variable_name}} placeholders.
var variableRe = regexp.MustCompile(`\{\{(\w+)\}\}`)
// variableRe matches {{variable_name}} placeholders, tolerating surrounding
// whitespace inside the braces (e.g. {{ name }}). This is the canonical
// template grammar shared by skill prompts and the extension template API
// (pkg/kit ParseTemplate/RenderTemplate delegate here).
var variableRe = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`)
// NewPromptTemplate creates a PromptTemplate, automatically extracting
// variable names from {{...}} placeholders in content.
@@ -50,11 +53,13 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) {
// Expand replaces all {{variable}} placeholders with values from the
// provided map. Missing variables are left as-is (no error).
func (t *PromptTemplate) Expand(values map[string]string) string {
result := t.Content
for k, v := range values {
result = strings.ReplaceAll(result, "{{"+k+"}}", v)
}
return result
return variableRe.ReplaceAllStringFunc(t.Content, func(m string) string {
name := variableRe.FindStringSubmatch(m)[1]
if v, ok := values[name]; ok {
return v
}
return m
})
}
// ExpandStrict replaces all {{variable}} placeholders and returns an error
+146 -92
View File
@@ -47,6 +47,7 @@ type MCPConnection struct {
client client.MCPClient
serverName string
serverConfig config.MCPServerConfig
initResult *mcp.InitializeResult // captured at handshake; nil before initialize
lastUsed time.Time
isHealthy bool
errorCount int
@@ -243,10 +244,12 @@ func (p *MCPConnectionPool) performHealthCheck(ctx context.Context, conn *MCPCon
// createConnection creates a new connection
func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (*MCPConnection, error) {
oauthEnabled := p.oauthFlow != nil && !serverConfig.NoOAuth
mcpClient, err := p.createMCPClient(ctx, serverName, serverConfig)
if err != nil {
// SSE transport can return OAuth error during Start()
if p.oauthFlow != nil && IsOAuthError(err) {
if oauthEnabled && IsOAuthError(err) {
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
}
@@ -260,15 +263,17 @@ func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName str
}
}
if err := p.initializeClient(ctx, mcpClient); err != nil {
conn := &MCPConnection{}
if err := p.initializeClient(ctx, mcpClient, conn); err != nil {
// Streamable HTTP transport returns OAuth error during Initialize()
if p.oauthFlow != nil && IsOAuthError(err) {
if oauthEnabled && IsOAuthError(err) {
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
_ = mcpClient.Close()
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
}
// Retry initialization after successful auth
if err := p.initializeClient(ctx, mcpClient); err != nil {
if err := p.initializeClient(ctx, mcpClient, conn); err != nil {
_ = mcpClient.Close()
return nil, err
}
@@ -278,15 +283,11 @@ func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName str
}
}
conn := &MCPConnection{
client: mcpClient,
serverName: serverName,
serverConfig: serverConfig,
lastUsed: time.Now(),
isHealthy: true,
errorCount: 0,
lastError: nil,
}
conn.client = mcpClient
conn.serverName = serverName
conn.serverConfig = serverConfig
conn.lastUsed = time.Now()
conn.isHealthy = true
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Created connection for %s", serverName))
@@ -344,49 +345,70 @@ func (p *MCPConnectionPool) createStdioClient(ctx context.Context, serverConfig
return stdioClient, nil
}
// createSSEClient creates an SSE client
// parseHeaders parses "Key: Value" header strings into a map.
func parseHeaders(raw []string) map[string]string {
if len(raw) == 0 {
return nil
}
headers := make(map[string]string)
for _, header := range raw {
parts := strings.SplitN(header, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
headers[key] = value
}
}
if len(headers) == 0 {
return nil
}
return headers
}
// buildOAuthConfig constructs a transport.OAuthConfig from the server config
// and the pool's OAuth flow. Returns nil if OAuth is not applicable.
func (p *MCPConnectionPool) buildOAuthConfig(serverConfig config.MCPServerConfig) (*transport.OAuthConfig, error) {
if p.oauthFlow == nil || serverConfig.NoOAuth {
return nil, nil
}
tokenStore, err := p.createTokenStore(serverConfig.URL)
if err != nil {
return nil, fmt.Errorf("failed to create token store: %w", err)
}
cfg := &transport.OAuthConfig{
RedirectURI: p.oauthFlow.handler.RedirectURI(),
PKCEEnabled: true,
TokenStore: tokenStore,
}
if serverConfig.OAuthClientID != "" {
cfg.ClientID = serverConfig.OAuthClientID
}
if serverConfig.OAuthClientSecret != "" {
cfg.ClientSecret = serverConfig.OAuthClientSecret
}
if len(serverConfig.OAuthScopes) > 0 {
cfg.Scopes = serverConfig.OAuthScopes
}
return cfg, nil
}
func (p *MCPConnectionPool) createSSEClient(ctx context.Context, serverConfig config.MCPServerConfig) (client.MCPClient, error) {
var options []transport.ClientOption
if len(serverConfig.Headers) > 0 {
headers := make(map[string]string)
for _, header := range serverConfig.Headers {
parts := strings.SplitN(header, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
headers[key] = value
}
}
if len(headers) > 0 {
options = append(options, transport.WithHeaders(headers))
}
if headers := parseHeaders(serverConfig.Headers); headers != nil {
options = append(options, transport.WithHeaders(headers))
}
// Enable OAuth for remote transports when an auth handler is configured.
// The OAuthConfig uses PKCE and the handler's redirect URI. If the server
// config provides a pre-registered ClientID (for servers that don't support
// dynamic client registration, e.g. GitHub), it is passed through directly.
if p.oauthFlow != nil {
tokenStore, tsErr := p.createTokenStore(serverConfig.URL)
if tsErr != nil {
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
}
oauthCfg := transport.OAuthConfig{
RedirectURI: p.oauthFlow.handler.RedirectURI(),
PKCEEnabled: true,
TokenStore: tokenStore,
}
if serverConfig.OAuthClientID != "" {
oauthCfg.ClientID = serverConfig.OAuthClientID
}
if serverConfig.OAuthClientSecret != "" {
oauthCfg.ClientSecret = serverConfig.OAuthClientSecret
}
if len(serverConfig.OAuthScopes) > 0 {
oauthCfg.Scopes = serverConfig.OAuthScopes
}
options = append(options, transport.WithOAuth(oauthCfg))
// Enable OAuth for remote transports when an auth handler is configured
// and the server hasn't opted out via NoOAuth. Public MCP servers (e.g.
// PubMed) set NoOAuth to skip dynamic client registration and token
// exchange, which would otherwise fail with a 404.
oauthCfg, err := p.buildOAuthConfig(serverConfig)
if err != nil {
return nil, err
}
if oauthCfg != nil {
options = append(options, transport.WithOAuth(*oauthCfg))
}
sseClient, err := client.NewSSEMCPClient(serverConfig.URL, options...)
@@ -405,45 +427,18 @@ func (p *MCPConnectionPool) createSSEClient(ctx context.Context, serverConfig co
func (p *MCPConnectionPool) createStreamableClient(ctx context.Context, serverConfig config.MCPServerConfig) (client.MCPClient, error) {
var options []transport.StreamableHTTPCOption
if len(serverConfig.Headers) > 0 {
headers := make(map[string]string)
for _, header := range serverConfig.Headers {
parts := strings.SplitN(header, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
headers[key] = value
}
}
if len(headers) > 0 {
options = append(options, transport.WithHTTPHeaders(headers))
}
if headers := parseHeaders(serverConfig.Headers); headers != nil {
options = append(options, transport.WithHTTPHeaders(headers))
}
// Enable OAuth for remote transports when an auth handler is configured.
// The OAuthConfig uses PKCE and the handler's redirect URI. If the server
// config provides a pre-registered ClientID (for servers that don't support
// dynamic client registration, e.g. GitHub), it is passed through directly.
if p.oauthFlow != nil {
tokenStore, tsErr := p.createTokenStore(serverConfig.URL)
if tsErr != nil {
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
}
oauthCfg := transport.OAuthConfig{
RedirectURI: p.oauthFlow.handler.RedirectURI(),
PKCEEnabled: true,
TokenStore: tokenStore,
}
if serverConfig.OAuthClientID != "" {
oauthCfg.ClientID = serverConfig.OAuthClientID
}
if serverConfig.OAuthClientSecret != "" {
oauthCfg.ClientSecret = serverConfig.OAuthClientSecret
}
if len(serverConfig.OAuthScopes) > 0 {
oauthCfg.Scopes = serverConfig.OAuthScopes
}
options = append(options, transport.WithHTTPOAuth(oauthCfg))
// Enable OAuth for remote transports when an auth handler is configured
// and the server hasn't opted out via NoOAuth.
oauthCfg, err := p.buildOAuthConfig(serverConfig)
if err != nil {
return nil, err
}
if oauthCfg != nil {
options = append(options, transport.WithHTTPOAuth(*oauthCfg))
}
streamableClient, err := client.NewStreamableHttpClient(serverConfig.URL, options...)
@@ -484,8 +479,10 @@ func (p *MCPConnectionPool) createTokenStore(serverURL string) (transport.TokenS
return NewFileTokenStore(serverURL)
}
// initializeClient initializes the client
func (p *MCPConnectionPool) initializeClient(ctx context.Context, client client.MCPClient) error {
// initializeClient initializes the client and captures the server's
// initialize result on the supplied connection so callers can later
// inspect advertised capabilities (e.g. task support).
func (p *MCPConnectionPool) initializeClient(ctx context.Context, c client.MCPClient, conn *MCPConnection) error {
initCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
@@ -495,12 +492,21 @@ func (p *MCPConnectionPool) initializeClient(ctx context.Context, client client.
Name: "kit",
Version: "1.0.0",
}
initRequest.Params.Capabilities = mcp.ClientCapabilities{}
// Advertise task support so servers may return CreateTaskResult for
// long-running tools/call requests instead of blocking the connection
// until completion. The client is responsible for polling tasks/get and
// tasks/result until the task reaches a terminal state.
initRequest.Params.Capabilities = mcp.ClientCapabilities{
Tasks: mcp.NewTasksCapability(),
}
_, err := client.Initialize(initCtx, initRequest)
initResult, err := c.Initialize(initCtx, initRequest)
if err != nil {
return fmt.Errorf("initialization timeout or failed: %w", err)
}
if conn != nil {
conn.initResult = initResult
}
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
p.debugLogger.LogDebug("[POOL] Initialized MCP client")
@@ -615,6 +621,54 @@ func (c *MCPConnection) ServerName() string {
return c.serverName
}
// InitializeResult returns the result captured from the server's initialize
// response, or nil if the connection was created before initialize completed.
// Callers can inspect ServerCapabilities.Tasks to discover task-related
// capability advertisements.
func (c *MCPConnection) InitializeResult() *mcp.InitializeResult {
c.mu.RLock()
defer c.mu.RUnlock()
return c.initResult
}
// SupportsToolTasks reports whether the server advertised support for
// task-augmented tools/call requests. Returns false when the connection has
// not yet completed initialization or when the server omitted task
// capabilities.
func (c *MCPConnection) SupportsToolTasks() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return supportsToolTasksFromInit(c.initResult)
}
// supportsToolTasksFromInit reports whether the supplied InitializeResult
// advertises task-augmented tools/call support. Extracted to a free function
// for unit testing without standing up a connection.
func supportsToolTasksFromInit(init *mcp.InitializeResult) bool {
if init == nil || init.Capabilities.Tasks == nil {
return false
}
req := init.Capabilities.Tasks.Requests
if req == nil || req.Tools == nil {
return false
}
return req.Tools.Call != nil
}
// ServerSupportsToolTasks reports whether the named server's connection
// advertises task-augmented tools/call support. Returns false when no
// connection exists for the server or when the server didn't advertise the
// capability.
func (p *MCPConnectionPool) ServerSupportsToolTasks(serverName string) bool {
p.mu.RLock()
conn, ok := p.connections[serverName]
p.mu.RUnlock()
if !ok {
return false
}
return conn.SupportsToolTasks()
}
// GetClients returns a map of all MCP clients currently in the pool.
// The map keys are server names and values are the corresponding MCP client instances.
// The returned map is a copy and modifications won't affect the pool.
+235 -30
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
@@ -13,6 +14,7 @@ import (
log "github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
@@ -141,6 +143,11 @@ type MCPToolManager struct {
debug bool
debugLogger DebugLogger
// taskCfg controls task-augmented tools/call execution. The zero value
// means: auto-detect server capability, no progress callback, default
// poll/timeout.
taskCfg MCPTaskConfig
// onServerLoaded, if non-nil, is called when each server finishes loading.
// Called with server name, tool count, and error (nil on success).
onServerLoaded func(serverName string, toolCount int, err error)
@@ -220,6 +227,21 @@ func (m *MCPToolManager) SetOnToolsChanged(cb func()) {
m.onToolsChanged = cb
}
// SetTaskConfig sets the task-augmented tools/call configuration. Call
// this before LoadTools / AddServer if you want the per-server mode
// override and progress handler to take effect for the very first call.
// Subsequent calls replace the previous configuration wholesale.
func (m *MCPToolManager) SetTaskConfig(cfg MCPTaskConfig) {
m.taskCfg = cfg
}
// TaskConfig returns the manager's current task-augmented tools/call
// configuration. The zero value means: defer to per-server config and
// auto-detected capability, with no progress callback and default polling.
func (m *MCPToolManager) TaskConfig() MCPTaskConfig {
return m.taskCfg
}
// AddServer connects to a new MCP server at runtime and loads its tools.
// The server's tools are immediately available to the agent after this call.
// Returns the number of tools loaded from the server.
@@ -551,6 +573,14 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
// checks, OAuth re-authorization, and connection error tracking.
// The inputJSON parameter is the raw JSON arguments from the LLM.
// Returns the result content, error flag, and any execution error.
//
// When the per-server TasksMode resolves to "always", or to "auto" and the
// server advertised tasks/toolCalls capability during initialize, the call
// is augmented with TaskParams. If the server elects to respond with a
// CreateTaskResult the manager polls tasks/get / tasks/result until the
// task reaches a terminal state, transparently presenting the final
// CallToolResult-equivalent content to the agent layer. Context
// cancellation triggers a best-effort tasks/cancel.
func (m *MCPToolManager) ExecuteTool(ctx context.Context, prefixedName, inputJSON string) (*MCPToolResult, error) {
m.mu.Lock()
mapping, ok := m.toolMap[prefixedName]
@@ -582,49 +612,224 @@ func (m *MCPToolManager) ExecuteTool(ctx context.Context, prefixedName, inputJSO
return nil, fmt.Errorf("failed to get healthy connection from pool: %w", err)
}
callRequest := mcp.CallToolRequest{
Request: mcp.Request{
Method: "tools/call",
},
Params: mcp.CallToolParams{
Name: mapping.originalName,
Arguments: arguments,
},
callParams := mcp.CallToolParams{
Name: mapping.originalName,
Arguments: arguments,
}
// Call the MCP tool using the original (unprefixed) name
result, err := conn.client.CallTool(ctx, callRequest)
if err != nil {
// Handle OAuth re-authorization: token may have expired mid-session.
if m.connectionPool.oauthFlow != nil && IsOAuthError(err) {
if flowErr := m.connectionPool.oauthFlow.RunAuthFlow(ctx, mapping.serverName, err); flowErr != nil {
return nil, fmt.Errorf("OAuth re-authorization failed for tool %s: %w", mapping.originalName, flowErr)
}
// Retry the tool call after successful re-auth.
result, err = conn.client.CallTool(ctx, callRequest)
if err != nil {
m.connectionPool.HandleConnectionError(mapping.serverName, err)
return nil, fmt.Errorf("failed to call mcp tool after re-auth: %w", err)
}
} else {
// Mark connection as unhealthy for automatic recovery
m.connectionPool.HandleConnectionError(mapping.serverName, err)
return nil, fmt.Errorf("failed to call mcp tool: %w", err)
// Decide whether to augment the request with TaskParams. Modes:
// never — never augment (synchronous-only).
// always — always augment, even without server capability.
// auto — augment only when the server advertised tasks/toolCalls.
mode := m.resolveTaskMode(mapping.serverName, mapping.serverConfig)
useTask := mode == MCPTaskModeAlways ||
(mode == MCPTaskModeAuto && conn.SupportsToolTasks())
if useTask {
var ttl *int64
if m.taskCfg.DefaultTTL > 0 {
ms := m.taskCfg.DefaultTTL.Milliseconds()
ttl = &ms
}
callParams.Task = &mcp.TaskParams{TTL: ttl}
}
// Marshal the MCP result to JSON string
marshaledResult, err := json.Marshal(result)
// Synchronous fast path: no task augmentation. Use the upstream client
// helper which keeps content-block typing identical to historical
// behaviour.
if !useTask {
callRequest := mcp.CallToolRequest{
Request: mcp.Request{Method: "tools/call"},
Params: callParams,
}
var result *mcp.CallToolResult
err := m.withOAuthRetry(ctx, mapping.serverName, mapping.originalName, func() error {
var callErr error
result, callErr = conn.client.CallTool(ctx, callRequest)
return callErr
})
if err != nil {
return nil, err
}
return marshalToolResult(result)
}
// Task-augmented path. Bypass the upstream CallTool helper because its
// ParseCallToolResult requires a "content" field that is absent from a
// CreateTaskResult.
rawClient, ok := conn.client.(*client.Client)
if !ok {
// Older client implementations — fall back to the synchronous shape.
callParams.Task = nil
callRequest := mcp.CallToolRequest{
Request: mcp.Request{Method: "tools/call"},
Params: callParams,
}
result, callErr := conn.client.CallTool(ctx, callRequest)
if callErr != nil {
m.connectionPool.HandleConnectionError(mapping.serverName, callErr)
return nil, fmt.Errorf("failed to call mcp tool: %w", callErr)
}
return marshalToolResult(result)
}
var (
callResult *mcp.CallToolResult
taskResult *mcp.CreateTaskResult
)
err = m.withOAuthRetry(ctx, mapping.serverName, mapping.originalName, func() error {
var callErr error
callResult, taskResult, callErr = callToolWithTask(ctx, rawClient, callParams)
return callErr
})
if err != nil {
return nil, err
}
// Server chose to answer synchronously — same shape as the no-task path.
if callResult != nil {
return marshalToolResult(callResult)
}
// Asynchronous task path: poll until terminal, then return the result.
if taskResult == nil {
return nil, errors.New("mcp tools/call returned neither result nor task")
}
final, pollErr := pollTaskUntilTerminal(
ctx, rawClient, mapping.serverName, taskResult.Task,
m.taskCfg, m.taskCfg.Progress,
)
if pollErr != nil {
return nil, fmt.Errorf("task execution failed: %w", pollErr)
}
// Adapt TaskResultResult → CallToolResult for downstream JSON shape parity.
return marshalToolResult(&mcp.CallToolResult{
Content: final.Content,
StructuredContent: final.StructuredContent,
IsError: final.IsError,
})
}
// withOAuthRetry runs call once; when it fails with an OAuth error and an
// OAuth flow is configured, it re-authorizes the server and retries once.
// Connection failures are reported to the pool and wrapped uniformly. This
// consolidates the retry/error chain shared by the synchronous and
// task-augmented tool-call paths.
func (m *MCPToolManager) withOAuthRetry(ctx context.Context, serverName, toolName string, call func() error) error {
callErr := call()
if callErr == nil {
return nil
}
if m.connectionPool.oauthFlow != nil && IsOAuthError(callErr) {
if flowErr := m.connectionPool.oauthFlow.RunAuthFlow(ctx, serverName, callErr); flowErr != nil {
return fmt.Errorf("OAuth re-authorization failed for tool %s: %w", toolName, flowErr)
}
if callErr = call(); callErr != nil {
m.connectionPool.HandleConnectionError(serverName, callErr)
return fmt.Errorf("failed to call mcp tool after re-auth: %w", callErr)
}
return nil
}
m.connectionPool.HandleConnectionError(serverName, callErr)
return fmt.Errorf("failed to call mcp tool: %w", callErr)
}
// marshalToolResult converts an MCP CallToolResult into the JSON-encoded
// MCPToolResult shape returned to the agent.
func marshalToolResult(result *mcp.CallToolResult) (*MCPToolResult, error) {
if result == nil {
return nil, errors.New("mcp tool call returned nil result")
}
marshaled, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal mcp tool result: %w", err)
}
return &MCPToolResult{
Content: string(marshaledResult),
Content: string(marshaled),
IsError: result.IsError,
}, nil
}
// resolveTaskMode resolves the effective task mode for a given server.
// Programmatic overrides via SetTaskConfig take precedence over the
// per-server TasksMode in MCPServerConfig. Empty / unknown values map to
// MCPTaskModeAuto.
func (m *MCPToolManager) resolveTaskMode(name string, cfg config.MCPServerConfig) MCPTaskMode {
if m.taskCfg.PerServerMode != nil {
if v, ok := m.taskCfg.PerServerMode[name]; ok {
return v
}
}
return ParseTaskMode(cfg.TasksMode)
}
// ListServerTasks queries tasks/list on the named server and returns the
// active and recent tasks the server is willing to disclose. Errors are
// returned untouched (callers commonly ignore METHOD_NOT_FOUND when the
// server didn't advertise tasks/list capability).
func (m *MCPToolManager) ListServerTasks(ctx context.Context, serverName string) ([]MCPTaskInfo, error) {
c, err := m.taskClient(serverName)
if err != nil {
return nil, err
}
res, err := c.ListTasks(ctx, mcp.ListTasksRequest{})
if err != nil {
return nil, fmt.Errorf("tasks/list on %s: %w", serverName, err)
}
out := make([]MCPTaskInfo, 0, len(res.Tasks))
for _, t := range res.Tasks {
out = append(out, taskFromMCP(serverName, t))
}
return out, nil
}
// GetServerTask queries tasks/get for a single task on the named server.
func (m *MCPToolManager) GetServerTask(ctx context.Context, serverName, taskID string) (MCPTaskInfo, error) {
c, err := m.taskClient(serverName)
if err != nil {
return MCPTaskInfo{}, err
}
res, err := c.GetTask(ctx, mcp.GetTaskRequest{Params: mcp.GetTaskParams{TaskId: taskID}})
if err != nil {
return MCPTaskInfo{}, fmt.Errorf("tasks/get on %s: %w", serverName, err)
}
return taskFromMCP(serverName, res.Task), nil
}
// CancelServerTask issues tasks/cancel for a task on the named server.
// Returns the post-cancel task state when the server responded with one.
func (m *MCPToolManager) CancelServerTask(ctx context.Context, serverName, taskID string) (MCPTaskInfo, error) {
c, err := m.taskClient(serverName)
if err != nil {
return MCPTaskInfo{}, err
}
res, err := c.CancelTask(ctx, mcp.CancelTaskRequest{Params: mcp.CancelTaskParams{TaskId: taskID}})
if err != nil {
return MCPTaskInfo{}, fmt.Errorf("tasks/cancel on %s: %w", serverName, err)
}
return taskFromMCP(serverName, res.Task), nil
}
// taskClient returns the *client.Client for a server. Tasks endpoints are
// not part of the upstream MCPClient interface so callers must work with
// the concrete client. Returns an error when the connection is missing
// or backed by a non-standard client type.
func (m *MCPToolManager) taskClient(serverName string) (*client.Client, error) {
if m.connectionPool == nil {
return nil, fmt.Errorf("no connection pool available")
}
clients := m.connectionPool.GetClients()
raw, ok := clients[serverName]
if !ok {
return nil, fmt.Errorf("MCP server %q not loaded", serverName)
}
c, ok := raw.(*client.Client)
if !ok {
return nil, fmt.Errorf("MCP server %q does not support task RPCs", serverName)
}
return c, nil
}
// GetTools returns all loaded MCP tools from all configured MCP servers.
// Tools are returned with their prefixed names (serverName__toolName) to ensure uniqueness.
func (m *MCPToolManager) GetTools() []MCPTool {
+404
View File
@@ -0,0 +1,404 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
// MCPTaskMode controls when the connection pool augments tools/call requests
// with MCP task metadata. See https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks.
type MCPTaskMode string
const (
// MCPTaskModeAuto augments tools/call with task metadata only when the
// server advertises tasks/toolCalls capability during initialize.
MCPTaskModeAuto MCPTaskMode = "auto"
// MCPTaskModeNever forces every tools/call to be issued synchronously
// (no Task field in the request), regardless of server capability.
MCPTaskModeNever MCPTaskMode = "never"
// MCPTaskModeAlways always sets a Task field on the tools/call request,
// even when the server didn't advertise task support. The server may
// still respond synchronously; this just opts in unconditionally on
// the client side.
MCPTaskModeAlways MCPTaskMode = "always"
)
// ParseTaskMode normalises a per-server tasks-mode string from
// configuration. Empty input maps to MCPTaskModeAuto. Unknown values are
// also treated as MCPTaskModeAuto so a stray config typo never breaks
// existing flows.
func ParseTaskMode(s string) MCPTaskMode {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "auto":
return MCPTaskModeAuto
case "never", "off", "disabled":
return MCPTaskModeNever
case "always", "force":
return MCPTaskModeAlways
default:
return MCPTaskModeAuto
}
}
// MCPTaskInfo is the connection-layer view of an MCP Task. It mirrors the
// upstream mcp.Task but exposes Go-native types and includes the originating
// server name. SDK-level wrappers re-export this under public-facing names.
type MCPTaskInfo struct {
// Server is the configured MCP server name this task lives on.
Server string
// TaskID is the server-assigned identifier for the task.
TaskID string
// Status is the current task lifecycle state.
Status mcp.TaskStatus
// StatusMessage is an optional human-readable description.
StatusMessage string
// CreatedAt is the wall-clock time the task was created (best-effort
// parsed from the server's ISO-8601 timestamp; zero on parse failure).
CreatedAt time.Time
// UpdatedAt is the wall-clock time the task was last updated (best-
// effort parsed; zero on parse failure).
UpdatedAt time.Time
// TTL is the time-to-live the server intends to retain the task after
// creation. Zero means the server did not advertise a TTL.
TTL time.Duration
// PollInterval is the suggested polling interval. Zero means use the
// client's default.
PollInterval time.Duration
}
// MCPTaskProgress is emitted while the connection pool is waiting on a
// task-augmented tool call. It provides minimal feedback for SDK consumers
// that want to render progress widgets without subscribing to the full
// notifications/tasks/status channel (Phase 2).
type MCPTaskProgress struct {
Server string
TaskID string
Status mcp.TaskStatus
Message string
}
// MCPTaskProgressHandler is invoked once after a task is accepted and on
// every status transition observed by the polling loop. The final
// invocation always carries a terminal status. Implementations must not
// block; long work should be queued on a goroutine.
type MCPTaskProgressHandler func(MCPTaskProgress)
// MCPTaskConfig configures task-aware tool execution on the manager.
// All fields are optional; the zero value disables progress callbacks and
// applies sensible defaults.
type MCPTaskConfig struct {
// PerServerMode overrides the per-server TasksMode resolved from
// MCPServerConfig. Keys are server names. Missing entries fall back
// to the value from config. Used by SDK consumers that want to set
// modes programmatically.
PerServerMode map[string]MCPTaskMode
// DefaultTTL is the TTL hint sent in TaskParams when augmenting a
// tools/call. Zero means omit the TTL — let the server pick its own.
DefaultTTL time.Duration
// PollInterval is the fallback interval between tasks/get requests
// when the server does not suggest one. Zero defaults to 1 second.
PollInterval time.Duration
// MaxPollInterval caps the polling interval. Zero defaults to 5 seconds.
MaxPollInterval time.Duration
// Timeout is the maximum wall-clock duration to wait for a task to
// reach a terminal state. Zero defaults to 15 minutes. Independent
// of the per-call context deadline; whichever fires first wins.
Timeout time.Duration
// Progress, if non-nil, receives every status transition observed by
// the polling loop.
Progress MCPTaskProgressHandler
}
func (c MCPTaskConfig) resolved() MCPTaskConfig {
if c.PollInterval <= 0 {
c.PollInterval = 1 * time.Second
}
if c.MaxPollInterval <= 0 {
c.MaxPollInterval = 5 * time.Second
}
if c.Timeout <= 0 {
c.Timeout = 15 * time.Minute
}
return c
}
// requestIDCounter generates monotonically increasing JSON-RPC request IDs
// for low-level tools/call invocations that bypass the upstream client's
// ParseCallToolResult helper (necessary because that helper rejects task
// responses for lacking a "content" field).
//
// The counter is process-wide rather than per-manager so multiple managers
// or repeated calls within the same connection produce unique IDs.
var requestIDCounter atomic.Int64
func nextRequestID() mcp.RequestId {
return mcp.NewRequestId(requestIDCounter.Add(1))
}
// callToolWithTask issues tools/call directly on the transport so we can
// observe both response shapes:
//
// - {"content": [...], ...} — synchronous CallToolResult.
// - {"task": {...}, ...} — asynchronous CreateTaskResult.
//
// On success exactly one of (callResult, taskResult) is non-nil. The
// upstream client.CallTool helper parses the response with
// mcp.ParseCallToolResult which requires a "content" field, so it cannot
// be used for task-augmented calls.
func callToolWithTask(
ctx context.Context,
c *client.Client,
params mcp.CallToolParams,
) (callResult *mcp.CallToolResult, taskResult *mcp.CreateTaskResult, err error) {
tr := c.GetTransport()
if tr == nil {
return nil, nil, errors.New("mcp client has no transport")
}
req := transport.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
ID: nextRequestID(),
Method: string(mcp.MethodToolsCall),
Params: params,
}
resp, sendErr := tr.SendRequest(ctx, req)
if sendErr != nil {
return nil, nil, sendErr
}
if resp.Error != nil {
return nil, nil, resp.Error.AsError()
}
// Peek at the raw result to decide which shape we got.
var probe struct {
Task json.RawMessage `json:"task"`
Content json.RawMessage `json:"content"`
}
raw := resp.Result
if len(raw) == 0 {
return nil, nil, errors.New("empty tools/call result")
}
if uErr := json.Unmarshal(raw, &probe); uErr != nil {
return nil, nil, fmt.Errorf("decode tools/call result: %w", uErr)
}
if len(probe.Task) > 0 && string(probe.Task) != "null" {
// Task-augmented response.
var ct mcp.CreateTaskResult
if uErr := json.Unmarshal(raw, &ct); uErr != nil {
return nil, nil, fmt.Errorf("decode CreateTaskResult: %w", uErr)
}
return nil, &ct, nil
}
// Synchronous response — defer to the upstream parser so content blocks
// are typed correctly (TextContent, ImageContent, ResourceLink, etc.).
cr, pErr := mcp.ParseCallToolResult(&raw)
if pErr != nil {
return nil, nil, fmt.Errorf("parse CallToolResult: %w", pErr)
}
return cr, nil, nil
}
// pollTaskUntilTerminal blocks until the task reaches a terminal status,
// the context is cancelled, or the configured timeout elapses. On
// cancellation it best-effort issues tasks/cancel before returning.
func pollTaskUntilTerminal(
ctx context.Context,
c *client.Client,
serverName string,
task mcp.Task,
cfg MCPTaskConfig,
progress MCPTaskProgressHandler,
) (*mcp.TaskResultResult, error) {
cfg = cfg.resolved()
deadline := time.Now().Add(cfg.Timeout)
emit := func(status mcp.TaskStatus, msg string) {
if progress != nil {
progress(MCPTaskProgress{Server: serverName, TaskID: task.TaskId, Status: status, Message: msg})
}
}
emit(task.Status, task.StatusMessage)
current := task
interval := cfg.PollInterval
if current.PollInterval != nil && *current.PollInterval > 0 {
interval = time.Duration(*current.PollInterval) * time.Millisecond
}
if interval > cfg.MaxPollInterval {
interval = cfg.MaxPollInterval
}
for !current.Status.IsTerminal() {
if time.Now().After(deadline) {
cancelTaskBestEffort(c, current.TaskId)
return nil, fmt.Errorf("task %s timed out after %s", current.TaskId, cfg.Timeout)
}
// Wait between polls or abort early on context cancellation.
select {
case <-ctx.Done():
cancelTaskBestEffort(c, current.TaskId)
return nil, ctx.Err()
case <-time.After(interval):
}
got, err := c.GetTask(ctx, mcp.GetTaskRequest{
Params: mcp.GetTaskParams{TaskId: current.TaskId},
})
if err != nil {
// Transient transport hiccup — propagate immediately. The
// upstream agent layer treats this like any other tool error.
return nil, fmt.Errorf("tasks/get failed: %w", err)
}
current = got.Task
if current.Status != task.Status || current.StatusMessage != task.StatusMessage {
emit(current.Status, current.StatusMessage)
task = current
}
// Honour any updated suggested poll interval, capped at the limit.
if current.PollInterval != nil && *current.PollInterval > 0 {
interval = min(time.Duration(*current.PollInterval)*time.Millisecond, cfg.MaxPollInterval)
}
}
// Terminal state reached. Emit one last progress event and fetch the
// definitive tool result.
emit(current.Status, current.StatusMessage)
if current.Status == mcp.TaskStatusCancelled {
return nil, fmt.Errorf("task %s was cancelled", current.TaskId)
}
res, err := fetchTaskResult(ctx, c, current.TaskId)
if err != nil {
return nil, fmt.Errorf("tasks/result failed: %w", err)
}
if current.Status == mcp.TaskStatusFailed && res != nil && !res.IsError {
// The server flagged the task as failed but didn't decorate the
// result. Surface the status message so the caller still sees a
// useful tool-error.
return nil, fmt.Errorf("task %s failed: %s", current.TaskId, current.StatusMessage)
}
return res, nil
}
// fetchTaskResult issues tasks/result on the transport and parses the raw
// response. The upstream client.TaskResult helper delegates to
// mcp.ParseTaskResultResult which (as of mcp-go v0.51.0) looks for the
// content array under a nested "result" key that never exists in the
// wire format — leading to systematically empty Content. Doing the
// parse here keeps the polling path working until that is fixed upstream.
func fetchTaskResult(ctx context.Context, c *client.Client, taskID string) (*mcp.TaskResultResult, error) {
tr := c.GetTransport()
if tr == nil {
return nil, errors.New("mcp client has no transport")
}
req := transport.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
ID: nextRequestID(),
Method: string(mcp.MethodTasksResult),
Params: mcp.TaskResultParams{TaskId: taskID},
}
resp, err := tr.SendRequest(ctx, req)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, resp.Error.AsError()
}
// Manually decode the wire shape: {"_meta": {...}, "content": [...],
// "structuredContent": ..., "isError": bool}.
var shape struct {
Meta json.RawMessage `json:"_meta"`
Content []json.RawMessage `json:"content"`
StructuredContent any `json:"structuredContent"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(resp.Result, &shape); err != nil {
return nil, fmt.Errorf("decode tasks/result: %w", err)
}
out := &mcp.TaskResultResult{
StructuredContent: shape.StructuredContent,
IsError: shape.IsError,
}
if len(shape.Meta) > 0 && string(shape.Meta) != "null" {
var metaMap map[string]any
if err := json.Unmarshal(shape.Meta, &metaMap); err == nil {
out.Meta = mcp.NewMetaFromMap(metaMap)
}
}
for _, raw := range shape.Content {
var contentMap map[string]any
if err := json.Unmarshal(raw, &contentMap); err != nil {
return nil, fmt.Errorf("decode content block: %w", err)
}
parsed, err := mcp.ParseContent(contentMap)
if err != nil {
return nil, fmt.Errorf("parse content block: %w", err)
}
out.Content = append(out.Content, parsed)
}
return out, nil
}
// cancelTaskBestEffort issues tasks/cancel and ignores any error. Used on
// context cancellation paths where the connection is already going away.
func cancelTaskBestEffort(c *client.Client, taskID string) {
if c == nil || taskID == "" {
return
}
cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = c.CancelTask(cancelCtx, mcp.CancelTaskRequest{
Params: mcp.CancelTaskParams{TaskId: taskID},
})
}
// taskFromMCP converts a wire-format mcp.Task to our richer connection-
// layer view. Unparseable timestamps surface as the zero time.
func taskFromMCP(serverName string, t mcp.Task) MCPTaskInfo {
out := MCPTaskInfo{
Server: serverName,
TaskID: t.TaskId,
Status: t.Status,
StatusMessage: t.StatusMessage,
}
if t.CreatedAt != "" {
if v, err := time.Parse(time.RFC3339, t.CreatedAt); err == nil {
out.CreatedAt = v
}
}
if t.LastUpdatedAt != "" {
if v, err := time.Parse(time.RFC3339, t.LastUpdatedAt); err == nil {
out.UpdatedAt = v
}
}
if t.TTL != nil {
out.TTL = time.Duration(*t.TTL) * time.Millisecond
}
if t.PollInterval != nil {
out.PollInterval = time.Duration(*t.PollInterval) * time.Millisecond
}
return out
}
+294
View File
@@ -0,0 +1,294 @@
package tools
import (
"context"
"strings"
"testing"
"time"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// newTaskTestInProcessServer builds an in-process MCP server with a
// task-augmented tool. The handler simulates work by sleeping briefly
// before completing.
//
// Important: the upstream mcp-go server cancels the request context as
// soon as the synchronous part of the tools/call returns (see
// request_handler.go:85, `defer cancel()`). Task goroutines spawned by
// AddTaskTool inherit that context and therefore see context.Canceled
// the instant they start. Real-world transports (stdio, SSE, streamable
// HTTP) don't trip this because they keep the connection — and a
// background context — alive across the async work, but the in-process
// transport runs entirely on the request goroutine. To test the polling
// path realistically we detach from the request context here.
func newTaskTestInProcessServer(t *testing.T, workDuration time.Duration) *server.MCPServer {
t.Helper()
srv := server.NewMCPServer("task-test", "1.0.0",
server.WithToolCapabilities(true),
// list=true, cancel=true, toolCallTasks=true so capability detection,
// cancellation, and tool augmentation all flow through.
server.WithTaskCapabilities(true, true, true),
)
srv.AddTaskTool(
mcp.Tool{
Name: "long_running",
Description: "Sleep, then echo the input string.",
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"msg": map[string]any{"type": "string"},
},
},
Execution: &mcp.ToolExecution{
TaskSupport: mcp.TaskSupportRequired,
},
},
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CreateTaskResult, error) {
msg, _ := req.GetArguments()["msg"].(string)
// Detach from the request context so the task handler can
// outlive the synchronous request — see comment above.
time.Sleep(workDuration)
_ = ctx
return &mcp.CreateTaskResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: "echo:" + msg},
},
}, nil
},
)
return srv
}
// newSyncOnlyServer is a server that does NOT advertise task capability.
// Used to verify the auto-detect path keeps the sync semantics.
func newSyncOnlyServer() *server.MCPServer {
srv := server.NewMCPServer("sync-only", "1.0.0",
server.WithToolCapabilities(true),
)
srv.AddTool(
mcp.NewTool("greet",
mcp.WithDescription("Say hello"),
mcp.WithString("name", mcp.Required()),
),
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, _ := req.GetArguments()["name"].(string)
return mcp.NewToolResultText("hi " + name), nil
},
)
return srv
}
func TestConnectionPoolAdvertisesTaskCapability(t *testing.T) {
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil)
defer func() { _ = pool.Close() }()
srv := newTaskTestInProcessServer(t, 0)
cfg := config.MCPServerConfig{Type: "inprocess", InProcessServer: srv}
conn, err := pool.GetConnection(context.Background(), "tasks", cfg)
if err != nil {
t.Fatalf("GetConnection: %v", err)
}
init := conn.InitializeResult()
if init == nil {
t.Fatal("InitializeResult is nil after GetConnection")
}
if init.Capabilities.Tasks == nil {
t.Fatal("server did not advertise Tasks capability — initialize handshake regressed")
}
if !conn.SupportsToolTasks() {
t.Error("SupportsToolTasks should be true for a server with toolCallTasks=true")
}
if !pool.ServerSupportsToolTasks("tasks") {
t.Error("ServerSupportsToolTasks should mirror the connection's value")
}
}
func TestConnectionPoolDetectsAbsentTaskCapability(t *testing.T) {
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil)
defer func() { _ = pool.Close() }()
cfg := config.MCPServerConfig{Type: "inprocess", InProcessServer: newSyncOnlyServer()}
conn, err := pool.GetConnection(context.Background(), "sync", cfg)
if err != nil {
t.Fatalf("GetConnection: %v", err)
}
if conn.SupportsToolTasks() {
t.Error("SupportsToolTasks should be false for a server that didn't advertise the capability")
}
}
func TestSupportsToolTasksFromInit(t *testing.T) {
cases := []struct {
name string
in *mcp.InitializeResult
want bool
}{
{"nil", nil, false},
{"no tasks", &mcp.InitializeResult{}, false},
{"tasks no requests", &mcp.InitializeResult{
Capabilities: mcp.ServerCapabilities{Tasks: &mcp.TasksCapability{}},
}, false},
{"tasks with toolCalls", &mcp.InitializeResult{
Capabilities: mcp.ServerCapabilities{Tasks: mcp.NewTasksCapability()},
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := supportsToolTasksFromInit(tc.in); got != tc.want {
t.Errorf("supportsToolTasksFromInit() = %v, want %v", got, tc.want)
}
})
}
}
func TestParseTaskMode(t *testing.T) {
cases := []struct {
in string
want MCPTaskMode
}{
{"", MCPTaskModeAuto},
{"auto", MCPTaskModeAuto},
{"AUTO", MCPTaskModeAuto},
{"never", MCPTaskModeNever},
{"off", MCPTaskModeNever},
{"always", MCPTaskModeAlways},
{"force", MCPTaskModeAlways},
{"bogus", MCPTaskModeAuto},
}
for _, tc := range cases {
if got := ParseTaskMode(tc.in); got != tc.want {
t.Errorf("ParseTaskMode(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestExecuteToolPollsTaskToCompletion(t *testing.T) {
mgr := NewMCPToolManager()
mgr.SetTaskConfig(MCPTaskConfig{
PollInterval: 20 * time.Millisecond,
MaxPollInterval: 50 * time.Millisecond,
Timeout: 10 * time.Second,
})
cfg := config.MCPServerConfig{
Type: "inprocess",
InProcessServer: newTaskTestInProcessServer(t, 50*time.Millisecond),
}
if _, err := mgr.AddServer(context.Background(), "tasks", cfg); err != nil {
t.Fatalf("AddServer: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := mgr.ExecuteTool(ctx, "tasks__long_running", `{"msg":"hello"}`)
if err != nil {
t.Fatalf("ExecuteTool: %v", err)
}
if res.IsError {
t.Fatalf("expected non-error result, got %s", res.Content)
}
if !strings.Contains(res.Content, "echo:hello") {
t.Errorf("expected result to contain 'echo:hello', got %s", res.Content)
}
}
func TestExecuteToolHonorsNeverMode(t *testing.T) {
// Even though the server advertises tasks/toolCalls, "never" should
// keep the call synchronous. Since the tool is TaskSupportRequired,
// the server returns an error rather than running it sync — we just
// verify the error surfaces (not a poll-loop hang).
mgr := NewMCPToolManager()
mgr.SetTaskConfig(MCPTaskConfig{
PerServerMode: map[string]MCPTaskMode{"tasks": MCPTaskModeNever},
Timeout: 2 * time.Second,
})
cfg := config.MCPServerConfig{
Type: "inprocess",
InProcessServer: newTaskTestInProcessServer(t, 0),
}
if _, err := mgr.AddServer(context.Background(), "tasks", cfg); err != nil {
t.Fatalf("AddServer: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// We don't care which way the server fails the sync call; we just want
// to confirm we didn't hang in the polling loop and didn't panic.
_, err := mgr.ExecuteTool(ctx, "tasks__long_running", `{"msg":"x"}`)
if err == nil {
t.Fatal("expected an error when forcing sync execution of a task-required tool")
}
}
func TestExecuteToolEmitsProgress(t *testing.T) {
var statuses []mcp.TaskStatus
mgr := NewMCPToolManager()
mgr.SetTaskConfig(MCPTaskConfig{
PollInterval: 10 * time.Millisecond,
MaxPollInterval: 25 * time.Millisecond,
Timeout: 5 * time.Second,
Progress: func(p MCPTaskProgress) {
statuses = append(statuses, p.Status)
},
})
cfg := config.MCPServerConfig{
Type: "inprocess",
InProcessServer: newTaskTestInProcessServer(t, 30*time.Millisecond),
}
if _, err := mgr.AddServer(context.Background(), "tasks", cfg); err != nil {
t.Fatalf("AddServer: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := mgr.ExecuteTool(ctx, "tasks__long_running", `{"msg":"hi"}`); err != nil {
t.Fatalf("ExecuteTool: %v", err)
}
if len(statuses) == 0 {
t.Fatal("expected at least one progress event")
}
last := statuses[len(statuses)-1]
if !last.IsTerminal() {
t.Errorf("last progress event should be terminal, got %q", last)
}
}
func TestListGetCancelMCPTasksOnLoadedServer(t *testing.T) {
mgr := NewMCPToolManager()
cfg := config.MCPServerConfig{
Type: "inprocess",
InProcessServer: newTaskTestInProcessServer(t, 0),
}
if _, err := mgr.AddServer(context.Background(), "tasks", cfg); err != nil {
t.Fatalf("AddServer: %v", err)
}
ctx := context.Background()
// tasks/list — no in-flight tasks yet, so we just verify the call
// succeeds and returns an empty slice (or any slice; the exact length
// depends on server retention policy).
if _, err := mgr.ListServerTasks(ctx, "tasks"); err != nil {
t.Errorf("ListServerTasks: %v", err)
}
// Unknown server should error cleanly without panicking.
if _, err := mgr.GetServerTask(ctx, "unknown", "abc"); err == nil {
t.Error("GetServerTask on unknown server should error")
}
if _, err := mgr.CancelServerTask(ctx, "unknown", "abc"); err == nil {
t.Error("CancelServerTask on unknown server should error")
}
}
-63
View File
@@ -28,15 +28,6 @@ type blockRenderer struct {
// renderingOption configures block rendering
type renderingOption func(*blockRenderer)
// WithFullWidth returns a renderingOption that configures the block renderer
// to expand to the full available width of its container. When enabled, the
// block will fill the entire horizontal space rather than sizing to its content.
func WithFullWidth() renderingOption {
return func(c *blockRenderer) {
c.fullWidth = true
}
}
// WithNoBorder returns a renderingOption that disables all borders on the
// block, rendering content with only padding.
func WithNoBorder() renderingOption {
@@ -63,15 +54,6 @@ func WithBorderColor(c color.Color) renderingOption {
}
}
// WithMarginTop returns a renderingOption that sets the top margin
// for the block. The margin is specified in number of lines and adds
// vertical space above the block.
func WithMarginTop(margin int) renderingOption {
return func(c *blockRenderer) {
c.marginTop = margin
}
}
// WithMarginBottom returns a renderingOption that sets the bottom margin
// for the block. The margin is specified in number of lines and adds
// vertical space below the block.
@@ -81,24 +63,6 @@ func WithMarginBottom(margin int) renderingOption {
}
}
// WithPaddingLeft returns a renderingOption that sets the left padding
// for the block content. The padding is specified in number of characters
// and adds horizontal space between the left border and the content.
func WithPaddingLeft(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingLeft = padding
}
}
// WithPaddingRight returns a renderingOption that sets the right padding
// for the block content. The padding is specified in number of characters
// and adds horizontal space between the content and the right border.
func WithPaddingRight(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingRight = padding
}
}
// WithPaddingTop returns a renderingOption that sets the top padding
// for the block content. The padding is specified in number of lines
// and adds vertical space between the top border and the content.
@@ -117,33 +81,6 @@ func WithPaddingBottom(padding int) renderingOption {
}
}
// WithBackground returns a renderingOption that sets the background color
// for the entire block. The color parameter accepts any color.Color value,
// typically a lipgloss hex color (e.g. lipgloss.Color("#1e1e2e")).
func WithBackground(c color.Color) renderingOption {
return func(br *blockRenderer) {
br.background = &c
}
}
// WithForeground returns a renderingOption that overrides the default text
// foreground color (theme.Text) for the block. Useful for muted or
// de-emphasized content blocks.
func WithForeground(c color.Color) renderingOption {
return func(br *blockRenderer) {
br.foreground = &c
}
}
// WithWidth returns a renderingOption that sets a specific width for the block
// in characters. This overrides the default container width and allows precise
// control over the block's horizontal dimensions.
func WithWidth(width int) renderingOption {
return func(c *blockRenderer) {
c.width = width
}
}
// renderContentBlock renders content with configurable styling options
func renderContentBlock(content string, containerWidth int, options ...renderingOption) string {
renderer := &blockRenderer{

Some files were not shown because too many files have changed in this diff Show More